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 {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
_initTestDatabaseFromFile,
|
_initTestDatabaseFromFile,
|
||||||
|
_deleteStoredRoomSettingsForTests,
|
||||||
_setMemoryTimestampsForTests,
|
_setMemoryTimestampsForTests,
|
||||||
_setRegisteredGroupForTests,
|
_setRegisteredGroupForTests,
|
||||||
assignRoom,
|
assignRoom,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
getLatestPairedTaskForChat,
|
getLatestPairedTaskForChat,
|
||||||
getLatestTurnNumber,
|
getLatestTurnNumber,
|
||||||
getLastRespondingAgentType,
|
getLastRespondingAgentType,
|
||||||
|
getRegisteredGroup,
|
||||||
getMessagesSinceSeq,
|
getMessagesSinceSeq,
|
||||||
getNewMessagesBySeq,
|
getNewMessagesBySeq,
|
||||||
getOpenWorkItem,
|
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', () => {
|
describe('paired room registration', () => {
|
||||||
@@ -1938,6 +2262,36 @@ describe('paired room registration', () => {
|
|||||||
expect(getEffectiveRuntimeRoomMode('dc:explicit-single')).toBe('single');
|
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', () => {
|
it('lets explicit tribunal become runnable when the configured reviewer can run on the solo registration', () => {
|
||||||
_setRegisteredGroupForTests('dc:explicit-tribunal', {
|
_setRegisteredGroupForTests('dc:explicit-tribunal', {
|
||||||
name: 'Explicit Tribunal Claude',
|
name: 'Explicit Tribunal Claude',
|
||||||
|
|||||||
172
src/db.ts
172
src/db.ts
@@ -39,6 +39,7 @@ import {
|
|||||||
normalizeRoomModeSource,
|
normalizeRoomModeSource,
|
||||||
normalizeStoredAgentType,
|
normalizeStoredAgentType,
|
||||||
parseRegisteredGroupRow,
|
parseRegisteredGroupRow,
|
||||||
|
resolveStoredRoomCapabilityTypes,
|
||||||
resolveAssignedRoomFolder,
|
resolveAssignedRoomFolder,
|
||||||
updateStoredRoomMetadata,
|
updateStoredRoomMetadata,
|
||||||
} from './db/room-registration.js';
|
} from './db/room-registration.js';
|
||||||
@@ -313,6 +314,14 @@ export function _setStoredRoomOwnerAgentTypeForTests(
|
|||||||
).run(ownerAgentType, new Date().toISOString(), chatJid);
|
).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. */
|
/** @internal - for tests only. */
|
||||||
export function _setMemoryTimestampsForTests(
|
export function _setMemoryTimestampsForTests(
|
||||||
id: number,
|
id: number,
|
||||||
@@ -749,12 +758,11 @@ export function getRegisteredGroup(
|
|||||||
const requestedAgentType = normalizeStoredAgentType(agentType);
|
const requestedAgentType = normalizeStoredAgentType(agentType);
|
||||||
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const group = buildRegisteredGroupFromStoredSettings(
|
return buildRegisteredGroupFromStoredSettings(
|
||||||
db,
|
db,
|
||||||
stored,
|
stored,
|
||||||
requestedAgentType,
|
requestedAgentType,
|
||||||
);
|
);
|
||||||
if (group) return group;
|
|
||||||
}
|
}
|
||||||
return getLegacyRegisteredGroup(db, jid, agentType);
|
return getLegacyRegisteredGroup(db, jid, agentType);
|
||||||
}
|
}
|
||||||
@@ -763,31 +771,58 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings(
|
|||||||
jid: string,
|
jid: string,
|
||||||
group: RegisteredGroup,
|
group: RegisteredGroup,
|
||||||
): void {
|
): void {
|
||||||
|
const existingStored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
||||||
const existingRoomMode = getStoredRoomModeRow(jid);
|
const existingRoomMode = getStoredRoomModeRow(jid);
|
||||||
if (!isValidGroupFolder(group.folder)) {
|
if (!isValidGroupFolder(group.folder)) {
|
||||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||||
}
|
}
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
db.prepare(
|
const seededAgentType = group.agentType || 'claude-code';
|
||||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
|
const agentTypes = new Set<AgentType>(collectRegisteredAgentTypes(db, jid));
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
agentTypes.add(seededAgentType);
|
||||||
).run(
|
const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([
|
||||||
jid,
|
...agentTypes,
|
||||||
group.name,
|
]);
|
||||||
group.folder,
|
const roomMode =
|
||||||
group.trigger,
|
existingRoomMode?.source === 'explicit'
|
||||||
group.added_at,
|
? existingRoomMode.roomMode
|
||||||
group.agentConfig ? JSON.stringify(group.agentConfig) : null,
|
: inferredRoomMode;
|
||||||
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
const ownerAgentType =
|
||||||
group.isMain ? 1 : 0,
|
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType
|
||||||
group.agentType || 'claude-code',
|
? existingStored.ownerAgentType
|
||||||
group.workDir || null,
|
: 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 (existingStored) {
|
||||||
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
|
updateStoredRoomMetadata(db, jid, snapshot);
|
||||||
syncInferredRoomModeForJid(jid);
|
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();
|
tx();
|
||||||
}
|
}
|
||||||
@@ -874,18 +909,30 @@ export function assignRoom(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateRegisteredGroupName(jid: string, name: string): void {
|
export function updateRegisteredGroupName(jid: string, name: string): void {
|
||||||
db.prepare(
|
const plan = buildRoomRegistrationPlanForJid(jid, { name });
|
||||||
`UPDATE room_settings
|
if (!plan) {
|
||||||
SET name = ?, updated_at = ?
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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(
|
export function getAllRegisteredGroups(
|
||||||
@@ -921,6 +968,10 @@ export function getAllRegisteredGroups(
|
|||||||
|
|
||||||
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
|
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
|
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
||||||
|
if (stored) {
|
||||||
|
return resolveStoredRoomCapabilityTypes(db, stored);
|
||||||
|
}
|
||||||
return collectRegisteredAgentTypes(db, jid);
|
return collectRegisteredAgentTypes(db, jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +1028,63 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
|
|||||||
updateStoredRoomMetadata(db, chatJid, snapshot);
|
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(
|
function upsertStoredRoomMode(
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
roomMode: RoomMode,
|
roomMode: RoomMode,
|
||||||
@@ -1010,7 +1118,7 @@ export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearExplicitRoomMode(chatJid: string): void {
|
export function clearExplicitRoomMode(chatJid: string): void {
|
||||||
const agentTypes = getRegisteredAgentTypesForJid(chatJid);
|
const agentTypes = collectRegisteredAgentTypes(db, chatJid);
|
||||||
if (agentTypes.length === 0) {
|
if (agentTypes.length === 0) {
|
||||||
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
|
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -147,9 +147,28 @@ export function getLegacyRegisteredGroupRows(
|
|||||||
return (
|
return (
|
||||||
agentTypeFilter
|
agentTypeFilter
|
||||||
? database
|
? 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)
|
.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[];
|
) as RegisteredGroupDatabaseRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +177,10 @@ export function getLegacyRegisteredGroup(
|
|||||||
jid: string,
|
jid: string,
|
||||||
agentType?: string,
|
agentType?: string,
|
||||||
): (RegisteredGroup & { jid: string }) | undefined {
|
): (RegisteredGroup & { jid: string }) | undefined {
|
||||||
|
if (getStoredRoomSettingsRowFromDatabase(database, jid)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const row = (
|
const row = (
|
||||||
agentType
|
agentType
|
||||||
? database
|
? database
|
||||||
@@ -276,7 +299,7 @@ export function buildRegisteredGroupFromStoredSettings(
|
|||||||
stored: StoredRoomSettings,
|
stored: StoredRoomSettings,
|
||||||
requestedAgentType?: AgentType,
|
requestedAgentType?: AgentType,
|
||||||
): (RegisteredGroup & { jid: string }) | undefined {
|
): (RegisteredGroup & { jid: string }) | undefined {
|
||||||
const capabilityTypes = collectRegisteredAgentTypes(database, stored.chatJid);
|
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
|
||||||
const resolvedAgentType = requestedAgentType
|
const resolvedAgentType = requestedAgentType
|
||||||
? capabilityTypes.includes(requestedAgentType)
|
? capabilityTypes.includes(requestedAgentType)
|
||||||
? 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 {
|
function detectChannelPrefixForFolder(chatJid: string): string {
|
||||||
if (chatJid.startsWith('dc:')) return 'discord';
|
if (chatJid.startsWith('dc:')) return 'discord';
|
||||||
if (chatJid.startsWith('tg:')) return 'telegram';
|
if (chatJid.startsWith('tg:')) return 'telegram';
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ describe('executeBotOnlyPairedFollowUpAction', () => {
|
|||||||
|
|
||||||
expect(first).toBe(true);
|
expect(first).toBe(true);
|
||||||
expect(second).toBe(true);
|
expect(second).toBe(true);
|
||||||
expect(closeStdin).toHaveBeenCalledTimes(2);
|
expect(closeStdin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(closeStdin).toHaveBeenCalledWith();
|
||||||
expect(enqueue).toHaveBeenCalledTimes(1);
|
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||||
expect(log.info).toHaveBeenCalledWith(
|
expect(log.info).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -400,8 +400,10 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeStdin();
|
|
||||||
const scheduled = schedulePairedFollowUp(action.task, action.intentKind);
|
const scheduled = schedulePairedFollowUp(action.task, action.intentKind);
|
||||||
|
if (scheduled) {
|
||||||
|
closeStdin();
|
||||||
|
}
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
chatJid,
|
chatJid,
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ import {
|
|||||||
} from './message-runtime-flow.js';
|
} from './message-runtime-flow.js';
|
||||||
import * as config from './config.js';
|
import * as config from './config.js';
|
||||||
import { logger } from './logger.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 * as serviceRouting from './service-routing.js';
|
||||||
import type { Channel, RegisteredGroup } from './types.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 () => {
|
it('does not enqueue the same reviewer follow-up twice across different runs while task state is unchanged', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('paired follow-up scheduler', () => {
|
|||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
round_trip_count: 1,
|
round_trip_count: 1,
|
||||||
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const first = schedulePairedFollowUpOnce({
|
const first = schedulePairedFollowUpOnce({
|
||||||
@@ -47,6 +48,7 @@ describe('paired follow-up scheduler', () => {
|
|||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
round_trip_count: 1,
|
round_trip_count: 1,
|
||||||
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const first = schedulePairedFollowUpOnce({
|
const first = schedulePairedFollowUpOnce({
|
||||||
@@ -78,6 +80,7 @@ describe('paired follow-up scheduler', () => {
|
|||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
round_trip_count: 1,
|
round_trip_count: 1,
|
||||||
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
} as const,
|
} as const,
|
||||||
intentKind: 'reviewer-turn',
|
intentKind: 'reviewer-turn',
|
||||||
enqueue,
|
enqueue,
|
||||||
@@ -89,6 +92,7 @@ describe('paired follow-up scheduler', () => {
|
|||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
round_trip_count: 2,
|
round_trip_count: 2,
|
||||||
|
updated_at: '2026-03-30T00:00:01.000Z',
|
||||||
} as const,
|
} as const,
|
||||||
intentKind: 'reviewer-turn',
|
intentKind: 'reviewer-turn',
|
||||||
enqueue,
|
enqueue,
|
||||||
@@ -105,9 +109,43 @@ describe('paired follow-up scheduler', () => {
|
|||||||
taskId: 'task-1',
|
taskId: 'task-1',
|
||||||
taskStatus: 'review_ready',
|
taskStatus: 'review_ready',
|
||||||
roundTripCount: 3,
|
roundTripCount: 3,
|
||||||
|
taskUpdatedAt: '2026-03-30T00:00:00.000Z',
|
||||||
intentKind: 'reviewer-turn',
|
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', () => {
|
it('allows the same follow-up again after the TTL expires', () => {
|
||||||
@@ -119,6 +157,7 @@ describe('paired follow-up scheduler', () => {
|
|||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
round_trip_count: 1,
|
round_trip_count: 1,
|
||||||
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const first = schedulePairedFollowUpOnce({
|
const first = schedulePairedFollowUpOnce({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type ScheduledPairedFollowUpIntentKind =
|
|||||||
|
|
||||||
type ScheduledPairedFollowUpTask = Pick<
|
type ScheduledPairedFollowUpTask = Pick<
|
||||||
PairedTask,
|
PairedTask,
|
||||||
'id' | 'status' | 'round_trip_count'
|
'id' | 'status' | 'round_trip_count' | 'updated_at'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const SCHEDULED_PAIRED_FOLLOW_UP_TTL_MS = 10 * 60 * 1000;
|
export const SCHEDULED_PAIRED_FOLLOW_UP_TTL_MS = 10 * 60 * 1000;
|
||||||
@@ -26,12 +26,14 @@ export function buildPairedFollowUpKey(args: {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
taskStatus: PairedTaskStatus | null;
|
taskStatus: PairedTaskStatus | null;
|
||||||
roundTripCount: number;
|
roundTripCount: number;
|
||||||
|
taskUpdatedAt: string | null | undefined;
|
||||||
intentKind: ScheduledPairedFollowUpIntentKind;
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
||||||
}): string {
|
}): string {
|
||||||
return [
|
return [
|
||||||
args.taskId,
|
args.taskId,
|
||||||
args.taskStatus ?? 'unknown',
|
args.taskStatus ?? 'unknown',
|
||||||
String(args.roundTripCount),
|
String(args.roundTripCount),
|
||||||
|
args.taskUpdatedAt ?? 'unknown',
|
||||||
args.intentKind,
|
args.intentKind,
|
||||||
].join(':');
|
].join(':');
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,7 @@ export function schedulePairedFollowUpOnce(args: {
|
|||||||
taskId: args.task.id,
|
taskId: args.task.id,
|
||||||
taskStatus: args.task.status,
|
taskStatus: args.task.status,
|
||||||
roundTripCount: args.task.round_trip_count,
|
roundTripCount: args.task.round_trip_count,
|
||||||
|
taskUpdatedAt: args.task.updated_at,
|
||||||
intentKind: args.intentKind,
|
intentKind: args.intentKind,
|
||||||
}),
|
}),
|
||||||
].join(':');
|
].join(':');
|
||||||
|
|||||||
Reference in New Issue
Block a user