import { getLatestOpenPairedTaskForChat, getMessagesSinceSeq, getOpenWorkItemForChat, markWorkItemDelivered, type WorkItem, } from './db.js'; import { isSessionCommandSenderAllowed, SERVICE_SESSION_SCOPE, } from './config.js'; import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js'; import { enqueueGenericFollowUpAfterDeliveryRetry as enqueueDeliveryRetryFollowUp } from './message-runtime-dispatch.js'; import { processOpenWorkItemDelivery } from './message-runtime-delivery.js'; import { handleQueuedRunGates } from './message-runtime-gating.js'; import { runPendingPairedTurnIfNeeded, runQueuedGroupTurn, } from './message-runtime-queue.js'; import { advanceLastAgentCursor, filterLoopingPairedBotMessages, getProcessableMessages, shouldSkipBotOnlyCollaboration, } from './message-runtime-rules.js'; import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js'; import { transitionPairedTaskStatus } from './paired-task-status.js'; import { hasReviewerLease } from './service-routing.js'; import { getFixedRoleChannelName, getMissingRoleChannelMessage, } from './message-runtime-shared.js'; import { resolvePairedRoleChannels } from './message-runtime-role-channels.js'; import { getFreshHumanPreflightMessages, hasHumanMessageAfterWorkItem, } from './message-runtime-preflight-messages.js'; import { deliverCanonicalOutboundMessage } from './ipc-outbound-delivery.js'; import { findChannel, formatMessages } from './router.js'; import { createScopedLogger, logger } from './logger.js'; import type { AgentOutput } from './agent-runner.js'; import type { GroupQueue, GroupRunContext } from './group-queue.js'; import type { ExecuteTurnFn, RoleToChannelMap, } from './message-runtime-types.js'; import type { Channel, NewMessage, PairedTask, RegisteredGroup, } from './types.js'; type RunAgentFn = ( group: RegisteredGroup, prompt: string, chatJid: string, runId: string, onOutput?: (output: AgentOutput) => Promise, ) => Promise<'success' | 'error'>; interface ProcessGroupMessagesDeps { assistantName: string; failureFinalText: string; timezone: string; triggerPattern: RegExp; channels: Channel[]; queue: Pick; getRoomBindings: () => Record; getLastAgentTimestamps: () => Record; saveState: () => void; clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; runAgent: RunAgentFn; executeTurn: ExecuteTurnFn; hasImplicitContinuationWindow: ( chatJid: string, messages: NewMessage[], ) => boolean; openContinuation: (chatJid: string) => void; isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean; labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[]; } interface RuntimeContext { chatJid: string; runId: string; group: RegisteredGroup; channel: Channel; log: ReturnType; roleToChannel: RoleToChannelMap; } interface PendingDeliveryState { pendingTask: PairedTask | null; openWorkItem: WorkItem | undefined; } export function createProcessGroupMessages( args: ProcessGroupMessagesDeps, ): (chatJid: string, context: GroupRunContext) => Promise { return async (chatJid, context): Promise => { const runtime = resolveRuntimeContext(args, chatJid, context.runId); if (!runtime) return true; const pendingDelivery = resolvePendingDeliveryState(args, runtime); const deliveryOutcome = await deliverPendingWorkItem( args, runtime, pendingDelivery, ); if (deliveryOutcome !== null) { return deliveryOutcome; } return processMissedMessages(args, runtime); }; } function resolveRuntimeContext( args: ProcessGroupMessagesDeps, chatJid: string, runId: string, ): RuntimeContext | null { const group = args.getRoomBindings()[chatJid]; if (!group) return null; const log = createScopedLogger({ chatJid, groupName: group.name, groupFolder: group.folder, runId, }); const channel = findChannel(args.channels, chatJid); if (!channel) { log.warn('No channel owns JID, skipping messages'); return null; } const { roleToChannel, reviewerChannelName, foundReviewerChannel, arbiterChannelName, foundArbiterChannel, } = resolvePairedRoleChannels(args.channels, channel); if (hasReviewerLease(chatJid)) { log.info( { reviewerChannelName, foundChannel: foundReviewerChannel?.name ?? null, arbiterChannelName, foundArbiterChannel: foundArbiterChannel?.name ?? null, availableChannels: args.channels.map((c) => c.name), }, 'Paired room reviewer/arbiter channel resolution', ); } return { chatJid, runId, group, channel, log, roleToChannel }; } function resolvePendingDeliveryState( args: ProcessGroupMessagesDeps, runtime: RuntimeContext, ): PendingDeliveryState { const { chatJid, channel, group, log } = runtime; let pendingTask: PairedTask | null = hasReviewerLease(chatJid) ? (getLatestOpenPairedTaskForChat(chatJid) ?? null) : null; let openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE); if (openWorkItem?.delivery_role !== 'owner' || !pendingTask) { return { pendingTask, openWorkItem }; } const freshHumanMessages = getFreshHumanPreflightMessages({ chatJid, channel, lastAgentTimestamps: args.getLastAgentTimestamps(), assistantName: args.assistantName, failureFinalText: args.failureFinalText, }); if (pendingTask.status === 'merge_ready' && freshHumanMessages.length > 0) { const resolvedTask = resolveOwnerTaskForHumanMessage({ group, chatJid, existingTask: pendingTask, }); pendingTask = resolvedTask.task ?? null; if (resolvedTask.supersededTask) { markWorkItemDelivered(openWorkItem.id); log.info( { chatJid, workItemId: openWorkItem.id, supersededTaskId: resolvedTask.supersededTask.id, replacementTaskId: resolvedTask.task?.id ?? null, }, 'Suppressed stale owner delivery retry because a new human message superseded the merge_ready task', ); openWorkItem = undefined; } } else if ( pendingTask.status === 'active' && hasHumanMessageAfterWorkItem(openWorkItem, freshHumanMessages) ) { markWorkItemDelivered(openWorkItem.id); log.info( { chatJid, workItemId: openWorkItem.id, taskId: pendingTask.id, taskStatus: pendingTask.status, workItemEndSeq: openWorkItem.end_seq ?? null, freshHumanMessageCount: freshHumanMessages.length, }, 'Suppressed stale owner delivery retry because a new human message arrived while the paired task was still active', ); openWorkItem = undefined; } return { pendingTask, openWorkItem }; } async function deliverPendingWorkItem( args: ProcessGroupMessagesDeps, runtime: RuntimeContext, pendingDelivery: PendingDeliveryState, ): Promise { const { chatJid, runId, group, channel, roleToChannel, log } = runtime; const openWorkItemOutcome = await processOpenWorkItemDelivery({ chatJid, runId, openWorkItem: pendingDelivery.openWorkItem, pendingTask: pendingDelivery.pendingTask, channel, roleToChannel, log, attachmentBaseDirs: resolveRuntimeAttachmentBaseDirs(group), isPairedRoom: hasReviewerLease(chatJid), getMissingRoleChannelMessage, isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal, openContinuation: args.openContinuation, enqueueFollowUpAfterDeliveryRetry: ({ deliveryRole, pendingTask, workItemId, }) => enqueueDeliveryRetryFollowUp({ chatJid, runId, deliveryRole, pendingTask, workItemId, log, enqueueMessageCheck: () => args.queue.enqueueMessageCheck(chatJid), }), }); if (openWorkItemOutcome === 'failed') { return false; } if (openWorkItemOutcome === 'delivered') { return true; } return null; } async function processMissedMessages( args: ProcessGroupMessagesDeps, runtime: RuntimeContext, ): Promise { while (true) { const rawMissedMessages = getMessagesSinceSeq( runtime.chatJid, args.getLastAgentTimestamps()[runtime.chatJid] || '0', args.assistantName, ); const missedMessages = filterLoopingPairedBotMessages( runtime.chatJid, getProcessableMessages( runtime.chatJid, rawMissedMessages, runtime.channel, ), args.failureFinalText, ); if (missedMessages.length === 0) { return handleNoMissedMessages(args, runtime, rawMissedMessages); } const pendingTurnOutcome = await runBotOnlyPendingTurn( args, runtime, rawMissedMessages, missedMessages, ); if (pendingTurnOutcome !== null) { return pendingTurnOutcome; } if (shouldSkipBotOnlyCollaboration(runtime.chatJid, missedMessages)) { advancePastBotOnlyCollaboration(args, runtime, missedMessages); return true; } const gateOutcome = await runQueuedRunGates(args, runtime, missedMessages); if (gateOutcome !== null) { return gateOutcome; } return runQueuedGroupTurn({ chatJid: runtime.chatJid, group: runtime.group, runId: runtime.runId, log: runtime.log, timezone: args.timezone, missedMessages, task: hasReviewerLease(runtime.chatJid) ? (getLatestOpenPairedTaskForChat(runtime.chatJid) ?? null) : undefined, roleToChannel: runtime.roleToChannel, ownerChannel: runtime.channel, lastAgentTimestamps: args.getLastAgentTimestamps(), saveState: args.saveState, executeTurn: args.executeTurn, getFixedRoleChannelName, labelPairedSenders: args.labelPairedSenders, formatMessages, }); } } async function handleNoMissedMessages( args: ProcessGroupMessagesDeps, runtime: RuntimeContext, rawMissedMessages: NewMessage[], ): Promise { const pendingTurnOutcome = await runPendingPairedTurnIfNeeded({ chatJid: runtime.chatJid, group: runtime.group, runId: runtime.runId, log: runtime.log, timezone: args.timezone, task: hasReviewerLease(runtime.chatJid) ? getLatestOpenPairedTaskForChat(runtime.chatJid) : null, rawMissedMessages, saveState: args.saveState, lastAgentTimestamps: args.getLastAgentTimestamps(), executeTurn: args.executeTurn, getFixedRoleChannelName, roleToChannel: runtime.roleToChannel, labelPairedSenders: args.labelPairedSenders, mode: 'idle', }); if (pendingTurnOutcome !== null) { return pendingTurnOutcome; } const lastIgnored = rawMissedMessages[rawMissedMessages.length - 1]; if (lastIgnored) { advanceLastAgentCursor( args.getLastAgentTimestamps(), args.saveState, runtime.chatJid, lastIgnored.timestamp, ); } return true; } function runBotOnlyPendingTurn( args: ProcessGroupMessagesDeps, runtime: RuntimeContext, rawMissedMessages: NewMessage[], missedMessages: NewMessage[], ): Promise { return runPendingPairedTurnIfNeeded({ chatJid: runtime.chatJid, group: runtime.group, runId: runtime.runId, log: runtime.log, timezone: args.timezone, task: hasReviewerLease(runtime.chatJid) ? getLatestOpenPairedTaskForChat(runtime.chatJid) : null, rawMissedMessages, saveState: args.saveState, lastAgentTimestamps: args.getLastAgentTimestamps(), executeTurn: args.executeTurn, getFixedRoleChannelName, roleToChannel: runtime.roleToChannel, labelPairedSenders: args.labelPairedSenders, mode: 'bot-only', missedMessages, }); } function advancePastBotOnlyCollaboration( args: ProcessGroupMessagesDeps, runtime: RuntimeContext, missedMessages: NewMessage[], ): void { const lastMessage = missedMessages[missedMessages.length - 1]; if (lastMessage?.seq != null) { advanceLastAgentCursor( args.getLastAgentTimestamps(), args.saveState, runtime.chatJid, lastMessage.seq, ); } runtime.log.info( 'Skipping bot-only collaboration because no recent human message exists', ); } async function runQueuedRunGates( args: ProcessGroupMessagesDeps, runtime: RuntimeContext, missedMessages: NewMessage[], ): Promise { const gateResult = await handleQueuedRunGates({ chatJid: runtime.chatJid, group: runtime.group, runId: runtime.runId, missedMessages, triggerPattern: args.triggerPattern, timezone: args.timezone, hasImplicitContinuationWindow: args.hasImplicitContinuationWindow, sessionCommandDeps: { sendMessage: (text) => deliverSessionCommandMessage(args, runtime.chatJid, text), setTyping: (typing) => runtime.channel.setTyping?.(runtime.chatJid, typing) ?? Promise.resolve(), runAgent: (prompt, onOutput) => args.runAgent( runtime.group, prompt, runtime.chatJid, runtime.runId, onOutput, ), closeStdin: () => args.queue.closeStdin(runtime.chatJid, { reason: 'session-command', }), clearSession: (opts) => args.clearSession(runtime.group.folder, opts), advanceCursor: (cursorOrTimestamp) => { advanceLastAgentCursor( args.getLastAgentTimestamps(), args.saveState, runtime.chatJid, cursorOrTimestamp, ); }, formatMessages, isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender), canSenderInteract: () => true, resetPairedTask: () => completePairedTaskAsStopped(runtime.chatJid), killProcess: () => args.queue.killProcess(runtime.chatJid), }, }); return gateResult.handled ? gateResult.success : null; } function completePairedTaskAsStopped(chatJid: string): void { if (!hasReviewerLease(chatJid)) return; const task = getLatestOpenPairedTaskForChat(chatJid); if (!task) return; const now = new Date().toISOString(); transitionPairedTaskStatus({ taskId: task.id, currentStatus: task.status, nextStatus: 'completed', expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { completion_reason: 'stopped', }, }); } async function deliverSessionCommandMessage( deps: Pick, chatJid: string, text: string, ): Promise { await deliverCanonicalOutboundMessage( { jid: chatJid, text }, { channels: deps.channels, roomBindings: deps.getRoomBindings, log: logger, }, ); }