Add paired delivery diagnostic logging
This commit is contained in:
@@ -113,6 +113,7 @@ vi.mock('discord.js', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
|
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
// --- Test helpers ---
|
// --- Test helpers ---
|
||||||
|
|
||||||
@@ -815,6 +816,32 @@ describe('DiscordChannel', () => {
|
|||||||
flags: 1 << 2,
|
flags: 1 << 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs channel name and Discord message ids after sending', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
await channel.sendMessage('dc:1234567890123456', 'Hello');
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
jid: 'dc:1234567890123456',
|
||||||
|
channelName: 'discord',
|
||||||
|
deliveryMode: 'send',
|
||||||
|
chunkCount: 1,
|
||||||
|
messageId: 'discord-message-1',
|
||||||
|
messageIds: ['discord-message-1'],
|
||||||
|
}),
|
||||||
|
'Discord message sent',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- ownsJid ---
|
// --- ownsJid ---
|
||||||
|
|||||||
@@ -533,9 +533,19 @@ export class DiscordChannel implements Channel {
|
|||||||
attachment: f,
|
attachment: f,
|
||||||
name: path.basename(f),
|
name: path.basename(f),
|
||||||
}));
|
}));
|
||||||
|
const sentMessageIds: string[] = [];
|
||||||
|
let chunkCount = 0;
|
||||||
|
|
||||||
|
const recordSentMessage = (message: Message | null | undefined) => {
|
||||||
|
chunkCount += 1;
|
||||||
|
if (message?.id) sentMessageIds.push(message.id);
|
||||||
|
};
|
||||||
|
|
||||||
if (!cleaned && files.length === 0) {
|
if (!cleaned && files.length === 0) {
|
||||||
logger.debug({ jid }, 'Skipping empty Discord outbound message');
|
logger.debug(
|
||||||
|
{ jid, channelName: this.name },
|
||||||
|
'Skipping empty Discord outbound message',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,17 +557,21 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
if (cleaned.length <= MAX_LENGTH) {
|
if (cleaned.length <= MAX_LENGTH) {
|
||||||
// Send text with first batch of files
|
// Send text with first batch of files
|
||||||
|
recordSentMessage(
|
||||||
await textChannel.send({
|
await textChannel.send({
|
||||||
content: cleaned || undefined,
|
content: cleaned || undefined,
|
||||||
files: fileBatches[0]?.length ? fileBatches[0] : undefined,
|
files: fileBatches[0]?.length ? fileBatches[0] : undefined,
|
||||||
flags: MessageFlags.SuppressEmbeds,
|
flags: MessageFlags.SuppressEmbeds,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
// Send remaining file batches as follow-up messages
|
// Send remaining file batches as follow-up messages
|
||||||
for (let b = 1; b < fileBatches.length; b++) {
|
for (let b = 1; b < fileBatches.length; b++) {
|
||||||
|
recordSentMessage(
|
||||||
await textChannel.send({
|
await textChannel.send({
|
||||||
files: fileBatches[b],
|
files: fileBatches[b],
|
||||||
flags: MessageFlags.SuppressEmbeds,
|
flags: MessageFlags.SuppressEmbeds,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Send text in chunks, attach first batch to the first chunk
|
// Send text in chunks, attach first batch to the first chunk
|
||||||
@@ -565,24 +579,43 @@ export class DiscordChannel implements Channel {
|
|||||||
for (let i = 0; i < cleaned.length; i += MAX_LENGTH) {
|
for (let i = 0; i < cleaned.length; i += MAX_LENGTH) {
|
||||||
const chunk = cleaned.slice(i, i + MAX_LENGTH);
|
const chunk = cleaned.slice(i, i + MAX_LENGTH);
|
||||||
const batch = fileBatches[fileBatchIndex];
|
const batch = fileBatches[fileBatchIndex];
|
||||||
|
recordSentMessage(
|
||||||
await textChannel.send({
|
await textChannel.send({
|
||||||
content: chunk,
|
content: chunk,
|
||||||
files: batch?.length ? batch : undefined,
|
files: batch?.length ? batch : undefined,
|
||||||
flags: MessageFlags.SuppressEmbeds,
|
flags: MessageFlags.SuppressEmbeds,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
if (batch?.length) fileBatchIndex++;
|
if (batch?.length) fileBatchIndex++;
|
||||||
}
|
}
|
||||||
// Send any remaining file batches
|
// Send any remaining file batches
|
||||||
for (let b = fileBatchIndex; b < fileBatches.length; b++) {
|
for (let b = fileBatchIndex; b < fileBatches.length; b++) {
|
||||||
|
recordSentMessage(
|
||||||
await textChannel.send({
|
await textChannel.send({
|
||||||
files: fileBatches[b],
|
files: fileBatches[b],
|
||||||
flags: MessageFlags.SuppressEmbeds,
|
flags: MessageFlags.SuppressEmbeds,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info({ jid, length: text.length }, 'Discord message sent');
|
logger.info(
|
||||||
|
{
|
||||||
|
jid,
|
||||||
|
channelName: this.name,
|
||||||
|
length: text.length,
|
||||||
|
deliveryMode: 'send',
|
||||||
|
chunkCount,
|
||||||
|
attachmentCount: files.length,
|
||||||
|
messageId: sentMessageIds[0] ?? null,
|
||||||
|
messageIds: sentMessageIds,
|
||||||
|
},
|
||||||
|
'Discord message sent',
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ jid, err }, 'Failed to send Discord message');
|
logger.error(
|
||||||
|
{ jid, channelName: this.name, err },
|
||||||
|
'Failed to send Discord message',
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,8 +813,21 @@ export class DiscordChannel implements Channel {
|
|||||||
if (!channel || !('messages' in channel)) return;
|
if (!channel || !('messages' in channel)) return;
|
||||||
const msg = await (channel as TextChannel).messages.fetch(messageId);
|
const msg = await (channel as TextChannel).messages.fetch(messageId);
|
||||||
await msg.edit(text);
|
await msg.edit(text);
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
jid,
|
||||||
|
channelName: this.name,
|
||||||
|
deliveryMode: 'edit',
|
||||||
|
messageId,
|
||||||
|
length: text.length,
|
||||||
|
},
|
||||||
|
'Discord message edited',
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug({ jid, messageId, err }, 'Failed to edit Discord message');
|
logger.debug(
|
||||||
|
{ jid, channelName: this.name, messageId, err },
|
||||||
|
'Failed to edit Discord message',
|
||||||
|
);
|
||||||
throw err; // Re-throw so callers (e.g. dashboard) can reset message ID
|
throw err; // Re-throw so callers (e.g. dashboard) can reset message ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/index.ts
20
src/index.ts
@@ -46,9 +46,9 @@ import { startIpcWatcher } from './ipc.js';
|
|||||||
import {
|
import {
|
||||||
findChannel,
|
findChannel,
|
||||||
findChannelByName,
|
findChannelByName,
|
||||||
findChannelForDeliveryRole,
|
|
||||||
formatOutbound,
|
formatOutbound,
|
||||||
normalizeMessageForDedupe,
|
normalizeMessageForDedupe,
|
||||||
|
resolveChannelForDeliveryRole,
|
||||||
} from './router.js';
|
} from './router.js';
|
||||||
import {
|
import {
|
||||||
buildRestartAnnouncement,
|
buildRestartAnnouncement,
|
||||||
@@ -473,9 +473,21 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
startIpcWatcher({
|
startIpcWatcher({
|
||||||
sendMessage: (jid, text, senderRole) => {
|
sendMessage: (jid, text, senderRole) => {
|
||||||
const channel = findChannelForDeliveryRole(channels, jid, senderRole);
|
const route = resolveChannelForDeliveryRole(channels, jid, senderRole);
|
||||||
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
if (!route.channel) throw new Error(`No channel for JID: ${jid}`);
|
||||||
return channel.sendMessage(jid, text);
|
logger.info(
|
||||||
|
{
|
||||||
|
transition: 'ipc:route',
|
||||||
|
chatJid: jid,
|
||||||
|
senderRole: senderRole ?? null,
|
||||||
|
requestedRoleChannel: route.requestedRoleChannelName,
|
||||||
|
selectedChannel: route.selectedChannelName,
|
||||||
|
usedRoleChannel: route.usedRoleChannel,
|
||||||
|
fallbackUsed: route.fallbackUsed,
|
||||||
|
},
|
||||||
|
'IPC relay routed message to channel',
|
||||||
|
);
|
||||||
|
return route.channel.sendMessage(jid, text);
|
||||||
},
|
},
|
||||||
nudgeScheduler: nudgeSchedulerLoop,
|
nudgeScheduler: nudgeSchedulerLoop,
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ describe('IPC message authorization', () => {
|
|||||||
it('forwards senderRole through authorized IPC messages', async () => {
|
it('forwards senderRole through authorized IPC messages', async () => {
|
||||||
const sendMessage = vi.fn(async () => {});
|
const sendMessage = vi.fn(async () => {});
|
||||||
|
|
||||||
await forwardAuthorizedIpcMessage(
|
const result = await forwardAuthorizedIpcMessage(
|
||||||
{
|
{
|
||||||
type: 'message',
|
type: 'message',
|
||||||
chatJid: 'other@g.us',
|
chatJid: 'other@g.us',
|
||||||
@@ -558,6 +558,12 @@ describe('IPC message authorization', () => {
|
|||||||
'review text',
|
'review text',
|
||||||
'reviewer',
|
'reviewer',
|
||||||
);
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
outcome: 'sent',
|
||||||
|
senderRole: 'reviewer',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
|
it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
|
||||||
@@ -577,6 +583,7 @@ describe('IPC message authorization', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.outcome).toBe('blocked');
|
expect(result.outcome).toBe('blocked');
|
||||||
|
expect(result.senderRole).toBe('reviewer');
|
||||||
expect(sendMessage).not.toHaveBeenCalled();
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface IpcMessageForwardResult {
|
|||||||
chatJid?: string;
|
chatJid?: string;
|
||||||
targetGroup?: string | null;
|
targetGroup?: string | null;
|
||||||
isMainOverride?: boolean;
|
isMainOverride?: boolean;
|
||||||
|
senderRole?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forwardAuthorizedIpcMessage(
|
export async function forwardAuthorizedIpcMessage(
|
||||||
@@ -73,7 +74,7 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
sendMessage: IpcDeps['sendMessage'],
|
sendMessage: IpcDeps['sendMessage'],
|
||||||
): Promise<IpcMessageForwardResult> {
|
): Promise<IpcMessageForwardResult> {
|
||||||
if (!(msg.type === 'message' && msg.chatJid && msg.text)) {
|
if (!(msg.type === 'message' && msg.chatJid && msg.text)) {
|
||||||
return { outcome: 'ignored' };
|
return { outcome: 'ignored', senderRole: msg.senderRole ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetGroup = registeredGroups[msg.chatJid];
|
const targetGroup = registeredGroups[msg.chatJid];
|
||||||
@@ -86,6 +87,7 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
chatJid: msg.chatJid,
|
chatJid: msg.chatJid,
|
||||||
targetGroup: targetGroup?.folder ?? null,
|
targetGroup: targetGroup?.folder ?? null,
|
||||||
isMainOverride,
|
isMainOverride,
|
||||||
|
senderRole: msg.senderRole ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
chatJid: msg.chatJid,
|
chatJid: msg.chatJid,
|
||||||
targetGroup: targetGroup?.folder ?? null,
|
targetGroup: targetGroup?.folder ?? null,
|
||||||
isMainOverride,
|
isMainOverride,
|
||||||
|
senderRole: msg.senderRole ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
sourceGroup,
|
sourceGroup,
|
||||||
targetGroup: forwardResult.targetGroup ?? null,
|
targetGroup: forwardResult.targetGroup ?? null,
|
||||||
isMainOverride: forwardResult.isMainOverride,
|
isMainOverride: forwardResult.isMainOverride,
|
||||||
|
senderRole: forwardResult.senderRole ?? null,
|
||||||
},
|
},
|
||||||
'IPC message sent',
|
'IPC message sent',
|
||||||
);
|
);
|
||||||
@@ -264,6 +268,7 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
sourceGroup,
|
sourceGroup,
|
||||||
targetGroup: forwardResult.targetGroup ?? null,
|
targetGroup: forwardResult.targetGroup ?? null,
|
||||||
isMainOverride: forwardResult.isMainOverride,
|
isMainOverride: forwardResult.isMainOverride,
|
||||||
|
senderRole: forwardResult.senderRole ?? null,
|
||||||
},
|
},
|
||||||
'Unauthorized IPC message attempt blocked',
|
'Unauthorized IPC message attempt blocked',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ import {
|
|||||||
resolveHandoffRoleOverride,
|
resolveHandoffRoleOverride,
|
||||||
} from './message-runtime.js';
|
} from './message-runtime.js';
|
||||||
import * as config from './config.js';
|
import * as config from './config.js';
|
||||||
|
import { logger } from './logger.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';
|
||||||
|
|
||||||
@@ -628,6 +629,90 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
|
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suppresses duplicate stale work item delivery and logs the suppression reason', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = makeGroup('codex');
|
||||||
|
const channel = makeChannel(chatJid);
|
||||||
|
const enqueueMessageCheck = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||||
|
vi.mocked(db.getOpenWorkItem).mockReturnValue({
|
||||||
|
id: 199,
|
||||||
|
group_folder: group.folder,
|
||||||
|
chat_jid: chatJid,
|
||||||
|
agent_type: 'codex',
|
||||||
|
service_id: 'claude',
|
||||||
|
delivery_role: 'owner',
|
||||||
|
status: 'delivery_retry',
|
||||||
|
start_seq: 1,
|
||||||
|
end_seq: 1,
|
||||||
|
result_payload: '같은 최종 답변입니다.',
|
||||||
|
delivery_attempts: 1,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
delivered_at: null,
|
||||||
|
delivery_message_id: null,
|
||||||
|
last_error: 'discord send failed',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLastBotFinalMessage).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'last-final',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'reviewer-bot@test',
|
||||||
|
sender_name: '리뷰어',
|
||||||
|
content: '같은 최종 답변입니다.',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
is_from_me: false,
|
||||||
|
is_bot_message: true,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = createMessageRuntime({
|
||||||
|
assistantName: 'Andy',
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
pollInterval: 1_000,
|
||||||
|
timezone: 'UTC',
|
||||||
|
triggerPattern: /^@Andy\b/i,
|
||||||
|
channels: [channel],
|
||||||
|
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-open-work-item-duplicate-suppressed',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(channel.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(db.markWorkItemDelivered).toHaveBeenCalledWith(199, null);
|
||||||
|
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
|
||||||
|
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid,
|
||||||
|
channelName: 'discord',
|
||||||
|
workItemId: 199,
|
||||||
|
deliveryRole: 'owner',
|
||||||
|
suppressionReason: 'paired-final-duplicate',
|
||||||
|
preview: '같은 최종 답변입니다.',
|
||||||
|
}),
|
||||||
|
'Suppressed duplicate final message in paired room (marked as delivered)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('retries a stale reviewer work item when the reviewer channel is missing', async () => {
|
it('retries a stale reviewer work item when the reviewer channel is missing', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
@@ -1325,6 +1410,17 @@ describe('createMessageRuntime', () => {
|
|||||||
chatJid,
|
chatJid,
|
||||||
resolveGroupIpcPath(group.folder),
|
resolveGroupIpcPath(group.folder),
|
||||||
);
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid,
|
||||||
|
taskId: 'task-merge-ready-inline-loop',
|
||||||
|
taskStatus: 'merge_ready',
|
||||||
|
handoffMode: 'inline-finalize',
|
||||||
|
nextRole: 'owner',
|
||||||
|
cursor: 42,
|
||||||
|
}),
|
||||||
|
'Executing merge_ready finalize turn inline after bot-only reviewer follow-up',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => {
|
it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => {
|
||||||
|
|||||||
@@ -319,6 +319,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
return isDuplicateOfLastBotFinal(chatJid, text);
|
return isDuplicateOfLastBotFinal(chatJid, text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildDeliveryLogContext = (
|
||||||
|
channel: Channel,
|
||||||
|
item: WorkItem,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
chatJid: item.chat_jid,
|
||||||
|
channelName: channel.name,
|
||||||
|
workItemId: item.id,
|
||||||
|
deliveryRole: item.delivery_role ?? null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
const deliverOpenWorkItem = async (
|
const deliverOpenWorkItem = async (
|
||||||
channel: Channel,
|
channel: Channel,
|
||||||
item: WorkItem,
|
item: WorkItem,
|
||||||
@@ -338,11 +350,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
// Mark as delivered without sending, and don't open continuation
|
// Mark as delivered without sending, and don't open continuation
|
||||||
markWorkItemDelivered(item.id, null);
|
markWorkItemDelivered(item.id, null);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
buildDeliveryLogContext(channel, item, {
|
||||||
chatJid: item.chat_jid,
|
|
||||||
workItemId: item.id,
|
|
||||||
preview: item.result_payload.slice(0, 100),
|
preview: item.result_payload.slice(0, 100),
|
||||||
},
|
suppressionReason: 'paired-final-duplicate',
|
||||||
|
}),
|
||||||
'Suppressed duplicate final message in paired room (marked as delivered)',
|
'Suppressed duplicate final message in paired room (marked as delivered)',
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -350,6 +361,14 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (replaceMessageId && channel.editMessage) {
|
if (replaceMessageId && channel.editMessage) {
|
||||||
|
logger.info(
|
||||||
|
buildDeliveryLogContext(channel, item, {
|
||||||
|
deliveryAttempts: item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'edit',
|
||||||
|
replacedMessageId: replaceMessageId,
|
||||||
|
}),
|
||||||
|
'Attempting to deliver produced work item by replacing tracked progress message',
|
||||||
|
);
|
||||||
await channel.editMessage(
|
await channel.editMessage(
|
||||||
item.chat_jid,
|
item.chat_jid,
|
||||||
replaceMessageId,
|
replaceMessageId,
|
||||||
@@ -358,39 +377,43 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
markWorkItemDelivered(item.id, replaceMessageId);
|
markWorkItemDelivered(item.id, replaceMessageId);
|
||||||
continuationTracker.open(item.chat_jid);
|
continuationTracker.open(item.chat_jid);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
buildDeliveryLogContext(channel, item, {
|
||||||
chatJid: item.chat_jid,
|
|
||||||
workItemId: item.id,
|
|
||||||
deliveryAttempts: item.delivery_attempts + 1,
|
deliveryAttempts: item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'edit',
|
||||||
replacedMessageId: replaceMessageId,
|
replacedMessageId: replaceMessageId,
|
||||||
},
|
}),
|
||||||
'Delivered produced work item by replacing tracked progress message',
|
'Delivered produced work item by replacing tracked progress message',
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
buildDeliveryLogContext(channel, item, {
|
||||||
chatJid: item.chat_jid,
|
|
||||||
workItemId: item.id,
|
|
||||||
deliveryAttempts: item.delivery_attempts + 1,
|
deliveryAttempts: item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'edit',
|
||||||
replacedMessageId: replaceMessageId,
|
replacedMessageId: replaceMessageId,
|
||||||
err,
|
err,
|
||||||
},
|
}),
|
||||||
'Failed to replace tracked progress message; falling back to a new message',
|
'Failed to replace tracked progress message; falling back to a new message',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
logger.info(
|
||||||
|
buildDeliveryLogContext(channel, item, {
|
||||||
|
deliveryAttempts: item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'send',
|
||||||
|
}),
|
||||||
|
'Attempting to deliver produced work item as a new message',
|
||||||
|
);
|
||||||
await channel.sendMessage(item.chat_jid, item.result_payload);
|
await channel.sendMessage(item.chat_jid, item.result_payload);
|
||||||
markWorkItemDelivered(item.id);
|
markWorkItemDelivered(item.id);
|
||||||
continuationTracker.open(item.chat_jid);
|
continuationTracker.open(item.chat_jid);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
buildDeliveryLogContext(channel, item, {
|
||||||
chatJid: item.chat_jid,
|
|
||||||
workItemId: item.id,
|
|
||||||
deliveryAttempts: item.delivery_attempts + 1,
|
deliveryAttempts: item.delivery_attempts + 1,
|
||||||
},
|
deliveryMode: 'send',
|
||||||
|
}),
|
||||||
'Delivered produced work item',
|
'Delivered produced work item',
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -398,12 +421,11 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
const errorMessage = getErrorMessage(err);
|
const errorMessage = getErrorMessage(err);
|
||||||
markWorkItemDeliveryRetry(item.id, errorMessage);
|
markWorkItemDeliveryRetry(item.id, errorMessage);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
buildDeliveryLogContext(channel, item, {
|
||||||
chatJid: item.chat_jid,
|
|
||||||
workItemId: item.id,
|
|
||||||
deliveryAttempts: item.delivery_attempts + 1,
|
deliveryAttempts: item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'send',
|
||||||
err,
|
err,
|
||||||
},
|
}),
|
||||||
'Failed to deliver produced work item',
|
'Failed to deliver produced work item',
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
@@ -1311,6 +1333,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
groupFolder: group.folder,
|
groupFolder: group.folder,
|
||||||
taskId: loopPendingTask?.id ?? null,
|
taskId: loopPendingTask?.id ?? null,
|
||||||
taskStatus: loopPendingTask?.status ?? null,
|
taskStatus: loopPendingTask?.status ?? null,
|
||||||
|
handoffMode: 'inline-finalize',
|
||||||
|
nextRole: 'owner',
|
||||||
|
cursor: mergeReadyCursor,
|
||||||
},
|
},
|
||||||
'Executing merge_ready finalize turn inline after bot-only reviewer follow-up',
|
'Executing merge_ready finalize turn inline after bot-only reviewer follow-up',
|
||||||
);
|
);
|
||||||
@@ -1374,6 +1399,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
groupFolder: group.folder,
|
groupFolder: group.folder,
|
||||||
taskId: loopPendingTask?.id ?? null,
|
taskId: loopPendingTask?.id ?? null,
|
||||||
taskStatus: loopPendingTask?.status ?? null,
|
taskStatus: loopPendingTask?.status ?? null,
|
||||||
|
handoffMode: 'requeue',
|
||||||
|
nextRole:
|
||||||
|
loopPendingTask?.status === 'review_ready'
|
||||||
|
? 'reviewer'
|
||||||
|
: loopPendingTask?.status === 'arbiter_requested' ||
|
||||||
|
loopPendingTask?.status === 'in_arbitration'
|
||||||
|
? 'arbiter'
|
||||||
|
: 'reviewer',
|
||||||
|
cursor: botOnlyPendingTurn.cursor,
|
||||||
|
cursorKey: botOnlyPendingTurn.cursorKey,
|
||||||
},
|
},
|
||||||
'Queued fresh paired pending turn instead of piping bot-only follow-up into the active agent',
|
'Queued fresh paired pending turn instead of piping bot-only follow-up into the active agent',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { findChannelForDeliveryRole } from './router.js';
|
import {
|
||||||
|
findChannelForDeliveryRole,
|
||||||
|
resolveChannelForDeliveryRole,
|
||||||
|
} from './router.js';
|
||||||
import { type Channel } from './types.js';
|
import { type Channel } from './types.js';
|
||||||
|
|
||||||
function createChannel(name: string, ownedJids: string[]): Channel {
|
function createChannel(name: string, ownedJids: string[]): Channel {
|
||||||
@@ -51,4 +54,41 @@ describe('findChannelForDeliveryRole', () => {
|
|||||||
ownerChannel,
|
ownerChannel,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports fallback metadata when a role-specific channel is unavailable', () => {
|
||||||
|
const ownerChannel = createChannel('discord-main', [jid]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveChannelForDeliveryRole([ownerChannel], jid, 'reviewer'),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: ownerChannel,
|
||||||
|
requestedRoleChannelName: 'discord-review',
|
||||||
|
selectedChannelName: 'discord-main',
|
||||||
|
usedRoleChannel: false,
|
||||||
|
fallbackUsed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports direct role routing metadata when the role channel exists', () => {
|
||||||
|
const ownerChannel = createChannel('discord-main', [jid]);
|
||||||
|
const reviewerChannel = createChannel('discord-review', [jid]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveChannelForDeliveryRole(
|
||||||
|
[ownerChannel, reviewerChannel],
|
||||||
|
jid,
|
||||||
|
'reviewer',
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: reviewerChannel,
|
||||||
|
requestedRoleChannelName: 'discord-review',
|
||||||
|
selectedChannelName: 'discord-review',
|
||||||
|
usedRoleChannel: true,
|
||||||
|
fallbackUsed: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,24 +93,64 @@ export function findChannel(
|
|||||||
return channels.find((c) => c.ownsJid(jid));
|
return channels.find((c) => c.ownsJid(jid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeliveryRouteResolution {
|
||||||
|
channel?: Channel;
|
||||||
|
requestedRoleChannelName: string | null;
|
||||||
|
selectedChannelName: string | null;
|
||||||
|
usedRoleChannel: boolean;
|
||||||
|
fallbackUsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestedRoleChannelName(senderRole?: string): string | null {
|
||||||
|
if (senderRole === 'reviewer') return 'discord-review';
|
||||||
|
if (senderRole === 'arbiter') return 'discord-arbiter';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChannelForDeliveryRole(
|
||||||
|
channels: Channel[],
|
||||||
|
jid: string,
|
||||||
|
senderRole?: string,
|
||||||
|
): DeliveryRouteResolution {
|
||||||
|
const requestedRoleChannelName = resolveRequestedRoleChannelName(senderRole);
|
||||||
|
if (!requestedRoleChannelName) {
|
||||||
|
const channel = findChannel(channels, jid);
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
requestedRoleChannelName,
|
||||||
|
selectedChannelName: channel?.name ?? null,
|
||||||
|
usedRoleChannel: false,
|
||||||
|
fallbackUsed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleChannel = findChannelByName(channels, requestedRoleChannelName);
|
||||||
|
if (roleChannel) {
|
||||||
|
return {
|
||||||
|
channel: roleChannel,
|
||||||
|
requestedRoleChannelName,
|
||||||
|
selectedChannelName: roleChannel.name,
|
||||||
|
usedRoleChannel: true,
|
||||||
|
fallbackUsed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackChannel = findChannel(channels, jid);
|
||||||
|
return {
|
||||||
|
channel: fallbackChannel,
|
||||||
|
requestedRoleChannelName,
|
||||||
|
selectedChannelName: fallbackChannel?.name ?? null,
|
||||||
|
usedRoleChannel: false,
|
||||||
|
fallbackUsed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function findChannelForDeliveryRole(
|
export function findChannelForDeliveryRole(
|
||||||
channels: Channel[],
|
channels: Channel[],
|
||||||
jid: string,
|
jid: string,
|
||||||
senderRole?: string,
|
senderRole?: string,
|
||||||
): Channel | undefined {
|
): Channel | undefined {
|
||||||
if (senderRole === 'reviewer') {
|
return resolveChannelForDeliveryRole(channels, jid, senderRole).channel;
|
||||||
return (
|
|
||||||
findChannelByName(channels, 'discord-review') ||
|
|
||||||
findChannel(channels, jid)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (senderRole === 'arbiter') {
|
|
||||||
return (
|
|
||||||
findChannelByName(channels, 'discord-arbiter') ||
|
|
||||||
findChannel(channels, jid)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return findChannel(channels, jid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findChannelByName(
|
export function findChannelByName(
|
||||||
|
|||||||
Reference in New Issue
Block a user