Merge branch 'codex/owner/ejclaw'

This commit is contained in:
ejclaw
2026-04-09 09:45:19 +09:00
8 changed files with 708 additions and 40 deletions

View File

@@ -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
View File

@@ -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;

View File

@@ -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';

View File

@@ -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({

View File

@@ -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,

View File

@@ -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');

View File

@@ -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({

View File

@@ -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(':');