import { getPairedTurnOutputs, getRecentChatMessages, getRecentDeliveredOwnerWorkItemsForChat, } from './db.js'; import { logger } from './logger.js'; import { buildArbiterPromptForTask, buildPairedTurnPrompt, } from './message-runtime-prompts.js'; import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js'; import { buildPendingPairedTurn, executePendingPairedTurn, isBotOnlyPairedRoomTurn, } from './message-runtime-flow.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { resolveStoredVisibleVerdict } from './paired-verdict.js'; import { advanceLastAgentCursor, resolveActiveRole, resolveCursorKeyForRole, resolveQueuedPairedTurnRole, resolveQueuedTurnRole, } from './message-runtime-rules.js'; import { claimPairedTurnExecution } from './paired-follow-up-scheduler.js'; import type { ExecuteTurnFn, RoleToChannelMap, } from './message-runtime-types.js'; import type { Channel, NewMessage, PairedTask, PairedTurnReservationIntentKind, RegisteredGroup, } from './types.js'; function resolveQueuedTurnReservationIntent(args: { task: PairedTask; turnRole: 'owner' | 'reviewer' | 'arbiter'; hasHumanMessage: boolean; }): PairedTurnReservationIntentKind { if (args.turnRole === 'reviewer') { return 'reviewer-turn'; } if (args.turnRole === 'arbiter') { return 'arbiter-turn'; } if (args.hasHumanMessage) { return 'owner-turn'; } if (args.task.status === 'merge_ready') { return 'finalize-owner-turn'; } return 'owner-follow-up'; } export async function runPendingPairedTurnIfNeeded(args: { chatJid: string; group: RegisteredGroup; runId: string; log: typeof logger; timezone: string; task: PairedTask | null | undefined; rawMissedMessages: NewMessage[]; saveState: () => void; lastAgentTimestamps: Record; executeTurn: ExecuteTurnFn; getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string; roleToChannel: RoleToChannelMap; labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[]; mode: 'idle' | 'bot-only'; missedMessages?: NewMessage[]; }): Promise { const { chatJid, task, roleToChannel } = args; if (!task) { return null; } if ( args.mode === 'bot-only' && (!args.missedMessages || !isBotOnlyPairedRoomTurn(chatJid, args.missedMessages)) ) { return null; } const recentMessages = getRecentChatMessages(chatJid, 20); const recentHumanMessages = recentMessages.filter( (message) => !message.is_bot_message, ); const labeledRecentMessages = args.labelPairedSenders( chatJid, recentMessages, ); const pendingTurn = buildPendingPairedTurn({ chatJid, timezone: args.timezone, task, rawMissedMessages: args.rawMissedMessages, recentHumanMessages, labeledRecentMessages, resolveChannel: (taskStatus) => roleToChannel[resolveActiveRole(taskStatus)] ?? null, }); if (!pendingTurn) { return null; } if (pendingTurn.channel) { const claimed = claimPairedTurnExecution({ chatJid, runId: args.runId, task, intentKind: pendingTurn.intentKind, }); if (!claimed) { args.log.info( { chatJid, taskId: task.id, taskStatus: task.status, taskUpdatedAt: task.updated_at, intentKind: pendingTurn.intentKind, }, 'Skipped paired pending turn because the task revision was already claimed elsewhere', ); return true; } } return executePendingPairedTurn({ pendingTurn, chatJid, group: args.group, runId: args.runId, log: args.log, saveState: args.saveState, lastAgentTimestamps: args.lastAgentTimestamps, executeTurn: args.executeTurn, getFixedRoleChannelName: args.getFixedRoleChannelName, }); } export async function runQueuedGroupTurn(args: { chatJid: string; group: RegisteredGroup; runId: string; log: typeof logger; timezone: string; missedMessages: NewMessage[]; task: PairedTask | null | undefined; roleToChannel: RoleToChannelMap; ownerChannel: Channel; lastAgentTimestamps: Record; saveState: () => void; executeTurn: ExecuteTurnFn; getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string; labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[]; formatMessages: (messages: NewMessage[], timezone: string) => string; }): Promise { const { chatJid, group, runId, log, missedMessages, task, roleToChannel } = args; let currentTask = task; const hasHumanMsg = task ? !missedMessages.every( (message) => message.is_from_me === true || !!message.is_bot_message, ) : !isBotOnlyPairedRoomTurn(chatJid, missedMessages); let fallbackMessages = missedMessages; if (currentTask && hasHumanMsg) { const resolvedTask = resolveOwnerTaskForHumanMessage({ group, chatJid, existingTask: currentTask, }); currentTask = resolvedTask.task; if (resolvedTask.supersededTask) { fallbackMessages = getRecentChatMessages(chatJid, 20).filter( (message) => !message.is_bot_message, ); } } const taskStatus = currentTask?.status; const turnOutputs = currentTask ? getPairedTurnOutputs(currentTask.id) : []; const lastTurnOutputRole = currentTask ? (turnOutputs.at(-1)?.role ?? null) : null; const lastTurnOutputVerdict = currentTask ? resolveStoredVisibleVerdict({ verdict: turnOutputs.at(-1)?.verdict ?? null, outputText: turnOutputs.at(-1)?.output_text ?? null, }) : null; const turnRole = currentTask ? hasHumanMsg ? resolveQueuedTurnRole({ taskStatus, hasHumanMessage: true, }) : resolveQueuedPairedTurnRole({ taskStatus, hasHumanMessage: false, lastTurnOutputRole, lastTurnOutputVerdict, }) : 'owner'; if (!turnRole) { const endSeq = missedMessages[missedMessages.length - 1]?.seq ?? null; if (endSeq !== null) { advanceLastAgentCursor( args.lastAgentTimestamps, args.saveState, chatJid, endSeq, resolveCursorKeyForRole(chatJid, resolveActiveRole(taskStatus)), ); } log.info( { chatJid, taskId: task?.id ?? null, taskStatus, lastTurnOutputRole, }, 'Skipped queued paired turn because the latest persisted turn already closed the pending handoff', ); return true; } const turnChannel = turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole]; const cursorKey = resolveCursorKeyForRole(chatJid, turnRole); const forcedRole = currentTask ? turnRole : undefined; const queuedIntentKind = currentTask ? resolveQueuedTurnReservationIntent({ task: currentTask, turnRole, hasHumanMessage: hasHumanMsg, }) : null; const pairedTurnIdentity = currentTask && queuedIntentKind ? buildPairedTurnIdentity({ taskId: currentTask.id, taskUpdatedAt: currentTask.updated_at, intentKind: queuedIntentKind, role: turnRole, }) : undefined; let prompt: string; if (turnRole === 'arbiter' && currentTask) { const recentMessages = getRecentChatMessages(chatJid, 20); prompt = buildArbiterPromptForTask({ task: currentTask, chatJid, timezone: args.timezone, turnOutputs, recentMessages, labeledRecentMessages: args.labelPairedSenders(chatJid, recentMessages), }); } else if (currentTask) { prompt = buildPairedTurnPrompt({ taskId: currentTask.id, chatJid, timezone: args.timezone, missedMessages, labeledFallbackMessages: args.labelPairedSenders( chatJid, fallbackMessages, ), turnOutputs, }); } else { // Single-mode (or paired-not-yet-activated) fallback. // The messages table only stores human messages, so the bot's own prior // answers (which live in work_items.result_payload) are otherwise invisible // to the next turn. Prepend the most recent delivered owner answers so the // model can reference what it previously said (e.g. "translate your last // answer to Korean"). const recentBotAnswers = getRecentDeliveredOwnerWorkItemsForChat( chatJid, 3, ); const formattedNew = args.formatMessages( args.labelPairedSenders(chatJid, missedMessages), args.timezone, ); if (recentBotAnswers.length === 0) { prompt = formattedNew; } else { const priorBlock = recentBotAnswers .map((item, idx) => { const label = `Prior bot answer ${idx + 1}/${recentBotAnswers.length} (delivered ${item.delivered_at ?? item.updated_at})`; return `${label}:\n${item.result_payload}`; }) .join('\n\n'); prompt = `[Recent prior answers from you in this channel — for context only, do not re-send]\n${priorBlock}\n\n[New messages to respond to]\n${formattedNew}`; } } const startSeq = missedMessages[0].seq ?? null; const endSeq = missedMessages[missedMessages.length - 1].seq ?? null; log.info( { messageCount: missedMessages.length, messageSeqStart: startSeq, messageSeqEnd: endSeq, }, 'Dispatching queued messages to agent', ); if (!turnChannel) { const missingRole = turnRole === 'arbiter' ? 'arbiter' : 'reviewer'; log.error( { taskStatus, role: turnRole, requiredChannel: args.getFixedRoleChannelName(missingRole), }, 'Skipping paired-room run because the dedicated Discord role channel is not configured', ); return false; } if (currentTask) { const claimed = claimPairedTurnExecution({ chatJid, runId, task: currentTask, intentKind: queuedIntentKind!, }); if (!claimed) { log.info( { taskId: currentTask.id, taskStatus, taskUpdatedAt: currentTask.updated_at, intentKind: queuedIntentKind, turnRole, }, 'Skipped queued paired turn because the task revision was already claimed elsewhere', ); return true; } } if (endSeq !== null) { advanceLastAgentCursor( args.lastAgentTimestamps, args.saveState, chatJid, endSeq, cursorKey, ); } const { deliverySucceeded, visiblePhase } = await args.executeTurn({ group, prompt, chatJid, runId, channel: turnChannel, deliveryRole: currentTask ? turnRole : undefined, startSeq, endSeq, hasHumanMessage: hasHumanMsg, forcedRole, pairedTurnIdentity, }); if (!deliverySucceeded) { log.warn( { messageSeqStart: startSeq, messageSeqEnd: endSeq, }, 'Persisted produced output for delivery retry without rerunning agent', ); return false; } log.info( { visiblePhase, messageSeqStart: startSeq, messageSeqEnd: endSeq, }, 'Queued run completed successfully', ); return true; }