Merge pull request #88 from phj1081/codex/owner/ejclaw

Route ordinary outbound through canonical delivery
This commit is contained in:
Eyejoker
2026-04-29 00:18:54 +09:00
committed by GitHub
5 changed files with 180 additions and 88 deletions

View File

@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from 'vitest';
import { import {
editFormattedTrackedChannelMessage, editFormattedTrackedChannelMessage,
isTerminalStatusMessage, isTerminalStatusMessage,
sendFormattedChannelMessage,
sendFormattedTrackedChannelMessage, sendFormattedTrackedChannelMessage,
} from './index.js'; } from './index.js';
import { composeDashboardContent } from './dashboard-render.js'; import { composeDashboardContent } from './dashboard-render.js';
@@ -81,12 +80,6 @@ describe('index scheduler messaging helpers', () => {
'msg-123', 'msg-123',
'<internal>only hidden</internal>', '<internal>only hidden</internal>',
); );
await sendFormattedChannelMessage(
[channel],
'dc:test',
'<internal>only hidden</internal>',
);
expect(trackedResult).toBeNull(); expect(trackedResult).toBeNull();
expect(sendAndTrack).not.toHaveBeenCalled(); expect(sendAndTrack).not.toHaveBeenCalled();
expect(editMessage).not.toHaveBeenCalled(); expect(editMessage).not.toHaveBeenCalled();

View File

@@ -30,10 +30,12 @@ import { composeDashboardContent } from './dashboard-render.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import { resolveGroupIpcPath } from './group-folder.js'; import { resolveGroupIpcPath } from './group-folder.js';
import { startIpcWatcher } from './ipc.js'; import { startIpcWatcher } from './ipc.js';
import { deliverIpcOutboundMessage } from './ipc-outbound-delivery.js'; import {
deliverCanonicalOutboundMessage,
deliverIpcOutboundMessage,
} from './ipc-outbound-delivery.js';
import { import {
findChannel, findChannel,
findChannelByName,
formatOutbound, formatOutbound,
normalizeMessageForDedupe, normalizeMessageForDedupe,
} from './router.js'; } from './router.js';
@@ -90,20 +92,6 @@ import { FAILOVER_MIN_DURATION_MS } from './config.js';
// Token rotation is initialized lazily on first use or at startup below // Token rotation is initialized lazily on first use or at startup below
export async function sendFormattedChannelMessage(
channels: Channel[],
jid: string,
rawText: string,
): Promise<void> {
const channel = findChannel(channels, jid);
if (!channel) {
logger.warn({ jid }, 'No channel owns JID, cannot send message');
return;
}
const text = formatOutbound(rawText);
if (text) await channel.sendMessage(jid, text);
}
export async function sendFormattedTrackedChannelMessage( export async function sendFormattedTrackedChannelMessage(
channels: Channel[], channels: Channel[],
jid: string, jid: string,
@@ -156,6 +144,23 @@ const runtime = createMessageRuntime({
clearSession: runtimeState.clearSession, clearSession: runtimeState.clearSession,
}); });
async function deliverFormattedCanonicalMessage(
jid: string,
rawText: string,
deliveryRole?: 'owner' | 'reviewer' | 'arbiter',
): Promise<void> {
const text = formatOutbound(rawText);
if (!text) return;
await deliverCanonicalOutboundMessage(
{ jid, text, deliveryRole },
{
channels,
roomBindings: runtimeState.getRoomBindings,
log: logger,
},
);
}
/** /**
* Get available groups list for the agent. * Get available groups list for the agent.
* Returns groups ordered by most recent activity. * Returns groups ordered by most recent activity.
@@ -192,8 +197,7 @@ async function announceRestartRecovery(
return explicitContext; return explicitContext;
} }
await sendFormattedChannelMessage( await deliverFormattedCanonicalMessage(
channels,
explicitContext.chatJid, explicitContext.chatJid,
buildRestartAnnouncement(explicitContext), buildRestartAnnouncement(explicitContext),
); );
@@ -207,8 +211,7 @@ async function announceRestartRecovery(
if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) { if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) {
continue; continue;
} }
await sendFormattedChannelMessage( await deliverFormattedCanonicalMessage(
channels,
interrupted.chatJid, interrupted.chatJid,
buildInterruptedRestartAnnouncement(interrupted), buildInterruptedRestartAnnouncement(interrupted),
); );
@@ -230,8 +233,7 @@ async function announceRestartRecovery(
return null; return null;
} }
await sendFormattedChannelMessage( await deliverFormattedCanonicalMessage(
channels,
inferred.chatJid, inferred.chatJid,
inferred.lines.join('\n'), inferred.lines.join('\n'),
); );
@@ -360,13 +362,6 @@ async function main(): Promise<void> {
} }
// Start subsystems (independently of connection handler) // Start subsystems (independently of connection handler)
// Paired-room scheduler output goes through the reviewer bot slot.
const reviewerChannelName = 'discord-review';
const reviewerChannelForCron = findChannelByName(
channels,
reviewerChannelName,
);
startSchedulerLoop({ startSchedulerLoop({
roomBindings: runtimeState.getRoomBindings, roomBindings: runtimeState.getRoomBindings,
getSessions: runtimeState.getSessions, getSessions: runtimeState.getSessions,
@@ -374,13 +369,9 @@ async function main(): Promise<void> {
onProcess: (groupJid, proc, processName, ipcDir) => onProcess: (groupJid, proc, processName, ipcDir) =>
queue.registerProcess(groupJid, proc, processName, ipcDir), queue.registerProcess(groupJid, proc, processName, ipcDir),
sendMessage: (jid, rawText) => sendMessage: (jid, rawText) =>
sendFormattedChannelMessage(channels, jid, rawText), deliverFormattedCanonicalMessage(jid, rawText),
sendMessageViaReviewerBot: reviewerChannelForCron sendMessageViaReviewerBot: (jid, rawText) =>
? async (jid, rawText) => { deliverFormattedCanonicalMessage(jid, rawText, 'reviewer'),
const text = formatOutbound(rawText);
if (text) await reviewerChannelForCron.sendMessage(jid, text);
}
: undefined,
sendTrackedMessage: (jid, rawText) => sendTrackedMessage: (jid, rawText) =>
sendFormattedTrackedChannelMessage(channels, jid, rawText), sendFormattedTrackedChannelMessage(channels, jid, rawText),
editTrackedMessage: (jid, messageId, rawText) => editTrackedMessage: (jid, messageId, rawText) =>

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { deliverIpcOutboundMessage } from './ipc-outbound-delivery.js'; import {
deliverCanonicalOutboundMessage,
deliverIpcOutboundMessage,
} from './ipc-outbound-delivery.js';
import type { WorkItem } from './db/work-items.js'; import type { WorkItem } from './db/work-items.js';
import type { Channel, RegisteredGroup } from './types.js'; import type { Channel, RegisteredGroup } from './types.js';
@@ -56,6 +59,47 @@ function makeWorkItem(input: {
} }
describe('deliverIpcOutboundMessage', () => { describe('deliverIpcOutboundMessage', () => {
it('funnels ordinary outbound through canonical work item delivery', async () => {
const channel = makeChannel();
const createdItem = makeWorkItem({
chat_jid: 'dc:room',
group_folder: 'room-folder',
result_payload: '일반 안내',
});
const createWorkItem = vi.fn(() => createdItem);
const deliverWorkItem = vi.fn(async () => true);
const result = await deliverCanonicalOutboundMessage(
{ jid: 'dc:room', text: '일반 안내' },
{
channels: [channel],
roomBindings: () => ({ 'dc:room': makeGroup() }),
createWorkItem,
deliverWorkItem,
},
);
expect(result).toBe('delivered');
expect(createWorkItem).toHaveBeenCalledWith({
group_folder: 'room-folder',
chat_jid: 'dc:room',
agent_type: 'codex',
delivery_role: null,
start_seq: null,
end_seq: null,
result_payload: '일반 안내',
attachments: undefined,
});
expect(deliverWorkItem).toHaveBeenCalledWith(
expect.objectContaining({
channel,
item: createdItem,
attachmentBaseDirs: ['/repo'],
}),
);
expect(channel.sendMessage).not.toHaveBeenCalled();
});
it('funnels IPC outbound through canonical work item delivery', async () => { it('funnels IPC outbound through canonical work item delivery', async () => {
const ownerChannel = makeChannel(); const ownerChannel = makeChannel();
const reviewerChannel = makeChannel('discord-review', false); const reviewerChannel = makeChannel('discord-review', false);
@@ -155,7 +199,7 @@ describe('deliverIpcOutboundMessage', () => {
deliverWorkItem, deliverWorkItem,
}, },
), ),
).rejects.toThrow('No registered room binding for IPC outbound JID'); ).rejects.toThrow('No registered room binding for outbound JID');
expect(createWorkItem).not.toHaveBeenCalled(); expect(createWorkItem).not.toHaveBeenCalled();
expect(deliverWorkItem).not.toHaveBeenCalled(); expect(deliverWorkItem).not.toHaveBeenCalled();

View File

@@ -43,6 +43,23 @@ export interface IpcOutboundDeliveryDeps {
deliverWorkItem?: typeof deliverOpenWorkItem; deliverWorkItem?: typeof deliverOpenWorkItem;
} }
export interface CanonicalOutboundDeliveryArgs {
jid: string;
text: string;
deliveryRole?: PairedRoomRole;
attachments?: OutboundAttachment[];
}
export interface CanonicalOutboundDeliveryDeps {
channels: Channel[];
roomBindings: () => Record<string, RegisteredGroup>;
log?: IpcDeliveryLog;
createWorkItem?: typeof createProducedWorkItem;
deliverWorkItem?: typeof deliverOpenWorkItem;
}
export type CanonicalOutboundDeliveryResult = 'delivered' | 'queued_retry';
export type IpcOutboundDeliveryResult = export type IpcOutboundDeliveryResult =
| 'delivered' | 'delivered'
| 'queued_retry' | 'queued_retry'
@@ -65,6 +82,61 @@ function isTerminalStatusMessage(text: string): boolean {
return parseVisibleVerdict(text) !== 'continue'; return parseVisibleVerdict(text) !== 'continue';
} }
export async function deliverCanonicalOutboundMessage(
args: CanonicalOutboundDeliveryArgs,
deps: CanonicalOutboundDeliveryDeps,
): Promise<CanonicalOutboundDeliveryResult> {
const log = deps.log ?? logger;
const group = deps.roomBindings()[args.jid];
if (!group) {
throw new Error(`No registered room binding for outbound JID: ${args.jid}`);
}
const route = resolveChannelForDeliveryRole(
deps.channels,
args.jid,
args.deliveryRole,
);
if (!route.channel) throw new Error(`No channel for JID: ${args.jid}`);
log.info(
{
transition: 'outbound:route',
chatJid: args.jid,
deliveryRole: args.deliveryRole ?? null,
requestedRoleChannel: route.requestedRoleChannelName,
selectedChannel: route.selectedChannelName,
usedRoleChannel: route.usedRoleChannel,
fallbackUsed: route.fallbackUsed,
},
'Routed outbound message to canonical work item delivery',
);
const createWorkItem = deps.createWorkItem ?? createProducedWorkItem;
const workItem = createWorkItem({
group_folder: group.folder,
chat_jid: args.jid,
agent_type: group.agentType ?? 'claude-code',
delivery_role: args.deliveryRole ?? null,
start_seq: null,
end_seq: null,
result_payload: args.text,
attachments: args.attachments,
});
const deliverWorkItem = deps.deliverWorkItem ?? deliverOpenWorkItem;
const delivered = await deliverWorkItem({
channel: route.channel,
item: workItem as WorkItem,
log,
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
isDuplicateOfLastBotFinal: () => false,
openContinuation: () => {},
});
return delivered ? 'delivered' : 'queued_retry';
}
export async function deliverIpcOutboundMessage( export async function deliverIpcOutboundMessage(
args: IpcOutboundDeliveryArgs, args: IpcOutboundDeliveryArgs,
deps: IpcOutboundDeliveryDeps, deps: IpcOutboundDeliveryDeps,
@@ -92,58 +164,34 @@ export async function deliverIpcOutboundMessage(
return 'skipped_recorded_terminal'; return 'skipped_recorded_terminal';
} }
const group = deps.roomBindings()[args.jid];
if (!group) {
throw new Error(
`No registered room binding for IPC outbound JID: ${args.jid}`,
);
}
const route = resolveChannelForDeliveryRole(
deps.channels,
args.jid,
deliveryRole,
);
if (!route.channel) throw new Error(`No channel for JID: ${args.jid}`);
log.info( log.info(
{ {
transition: 'ipc:route', transition: 'ipc:route',
chatJid: args.jid, chatJid: args.jid,
runId: args.runId ?? null, runId: args.runId ?? null,
senderRole: deliveryRole ?? null, senderRole: deliveryRole ?? null,
requestedRoleChannel: route.requestedRoleChannelName,
selectedChannel: route.selectedChannelName,
usedRoleChannel: route.usedRoleChannel,
fallbackUsed: route.fallbackUsed,
}, },
'IPC relay routed message to canonical work item delivery', 'IPC relay routed message to canonical work item delivery',
); );
const createWorkItem = deps.createWorkItem ?? createProducedWorkItem; const result = await deliverCanonicalOutboundMessage(
const workItem = createWorkItem({ {
group_folder: group.folder, jid: args.jid,
chat_jid: args.jid, text: args.text,
agent_type: group.agentType ?? 'claude-code', deliveryRole,
delivery_role: deliveryRole ?? null, attachments: args.attachments,
start_seq: null, },
end_seq: null, {
result_payload: args.text, channels: deps.channels,
attachments: args.attachments, roomBindings: deps.roomBindings,
}); log,
createWorkItem: deps.createWorkItem,
deliverWorkItem: deps.deliverWorkItem,
},
);
const deliverWorkItem = deps.deliverWorkItem ?? deliverOpenWorkItem; if (result !== 'delivered') {
const delivered = await deliverWorkItem({ return result;
channel: route.channel,
item: workItem as WorkItem,
log,
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
isDuplicateOfLastBotFinal: () => false,
openContinuation: () => {},
});
if (!delivered) {
return 'queued_retry';
} }
if ( if (

View File

@@ -20,6 +20,7 @@ import {
deliverOpenWorkItem, deliverOpenWorkItem,
processOpenWorkItemDelivery, processOpenWorkItemDelivery,
} from './message-runtime-delivery.js'; } from './message-runtime-delivery.js';
import { deliverCanonicalOutboundMessage } from './ipc-outbound-delivery.js';
import { handleQueuedRunGates } from './message-runtime-gating.js'; import { handleQueuedRunGates } from './message-runtime-gating.js';
import { import {
enqueuePendingHandoffs as enqueueClaimedServiceHandoffs, enqueuePendingHandoffs as enqueueClaimedServiceHandoffs,
@@ -93,6 +94,21 @@ export interface MessageRuntimeDeps {
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
} }
async function deliverSessionCommandMessage(
deps: Pick<MessageRuntimeDeps, 'channels' | 'getRoomBindings'>,
chatJid: string,
text: string,
): Promise<void> {
await deliverCanonicalOutboundMessage(
{ jid: chatJid, text },
{
channels: deps.channels,
roomBindings: deps.getRoomBindings,
log: logger,
},
);
}
export function createMessageRuntime(deps: MessageRuntimeDeps): { export function createMessageRuntime(deps: MessageRuntimeDeps): {
processGroupMessages: ( processGroupMessages: (
chatJid: string, chatJid: string,
@@ -110,7 +126,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid: string, chatJid: string,
messages: NewMessage[], messages: NewMessage[],
): NewMessage[] => labelPairedSenders(deps.channels, chatJid, messages); ): NewMessage[] => labelPairedSenders(deps.channels, chatJid, messages);
const enqueueScopedGroupMessageCheck = buildScopedMessageCheckEnqueuer( const enqueueScopedGroupMessageCheck = buildScopedMessageCheckEnqueuer(
deps.queue, deps.queue,
); );
@@ -587,7 +602,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
timezone: deps.timezone, timezone: deps.timezone,
hasImplicitContinuationWindow: continuationTracker.has, hasImplicitContinuationWindow: continuationTracker.has,
sessionCommandDeps: { sessionCommandDeps: {
sendMessage: (text) => channel.sendMessage(chatJid, text), sendMessage: (text) =>
deliverSessionCommandMessage(deps, chatJid, text),
setTyping: (typing) => setTyping: (typing) =>
channel.setTyping?.(chatJid, typing) ?? Promise.resolve(), channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
runAgent: (prompt, onOutput) => runAgent: (prompt, onOutput) =>