From b209061006551cb09e051a96f056029fa2e2a298 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 30 Mar 2026 20:23:16 +0900 Subject: [PATCH] fix: use separate cursors for owner and reviewer in paired rooms Root cause: owner and reviewer shared the same message cursor. When the owner's turn completed, the cursor advanced past the owner's message. The reviewer's turn then only saw messages after that cursor, missing the owner's response entirely. Now paired rooms use role-aware cursor keys (chatJid for owner, chatJid:reviewer for reviewer). Each role advances only its own cursor, so the reviewer always sees the owner's latest messages and vice versa. --- src/message-runtime-rules.ts | 15 ++++++++++-- src/message-runtime.ts | 44 ++++++++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index db2d8fa..cac11a2 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -16,11 +16,14 @@ export function advanceLastAgentCursor( saveState: () => void, chatJid: string, cursorOrTimestamp: string | number, + /** Override cursor key (e.g. `${chatJid}:reviewer` for paired rooms). */ + cursorKey?: string, ): void { + const key = cursorKey ?? chatJid; if (typeof cursorOrTimestamp === 'number') { - lastAgentTimestamps[chatJid] = String(cursorOrTimestamp); + lastAgentTimestamps[key] = String(cursorOrTimestamp); } else { - lastAgentTimestamps[chatJid] = normalizeStoredSeqCursor( + lastAgentTimestamps[key] = normalizeStoredSeqCursor( cursorOrTimestamp, chatJid, ); @@ -28,6 +31,14 @@ export function advanceLastAgentCursor( saveState(); } +/** Returns the cursor key for a paired room role. */ +export function pairedCursorKey( + chatJid: string, + isReviewerTurn: boolean, +): string { + return isReviewerTurn ? `${chatJid}:reviewer` : chatJid; +} + export function createImplicitContinuationTracker(idleTimeout: number) { const implicitContinuationUntil = new Map(); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 5f113a3..2593b1a 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -13,6 +13,7 @@ import { markWorkItemDeliveryRetry, getLastBotFinalMessage, getLastHumanMessageContent, + getRecentChatMessages, isPairedRoomJid, getLatestOpenPairedTaskForChat, updatePairedTask, @@ -36,6 +37,7 @@ import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; import { advanceLastAgentCursor, createImplicitContinuationTracker, + pairedCursorKey, filterLoopingPairedBotMessages, getProcessableMessages, hasAllowedTrigger, @@ -721,6 +723,19 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { return true; } + // Determine role BEFORE advancing cursor — paired rooms use + // separate cursors for owner and reviewer so neither misses + // the other's messages. + const pendingTaskForChannel = isPairedRoomJid(chatJid) + ? getLatestOpenPairedTaskForChat(chatJid) + : null; + const useReviewerChannel = + pendingTaskForChannel && + (pendingTaskForChannel.status === 'review_ready' || + pendingTaskForChannel.status === 'in_review'); + const turnChannel = useReviewerChannel ? reviewerChannel : channel; + const cursorKey = pairedCursorKey(chatJid, !!useReviewerChannel); + const prompt = formatMessages( labelPairedSenders(chatJid, missedMessages), deps.timezone, @@ -733,6 +748,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { deps.saveState, chatJid, endSeq, + cursorKey, ); } @@ -746,17 +762,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }, 'Dispatching queued messages to agent', ); - // Use reviewer channel when the agent will run in reviewer mode. - // This is determined by the paired task status — if review_ready - // or in_review, the executor switches to reviewer mode. - const pendingTaskForChannel = isPairedRoomJid(chatJid) - ? getLatestOpenPairedTaskForChat(chatJid) - : null; - const useReviewerChannel = - pendingTaskForChannel && - (pendingTaskForChannel.status === 'review_ready' || - pendingTaskForChannel.status === 'in_review'); - const turnChannel = useReviewerChannel ? reviewerChannel : channel; const hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages); const { deliverySucceeded, visiblePhase } = await executeTurn({ @@ -916,9 +921,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { continue; } + // Use role-aware cursor for paired rooms so the reviewer + // always sees the owner's last messages and vice versa. + const loopPendingTask = isPairedRoomJid(chatJid) + ? getLatestOpenPairedTaskForChat(chatJid) + : null; + const loopIsReviewerTurn = + !!loopPendingTask && + (loopPendingTask.status === 'review_ready' || + loopPendingTask.status === 'in_review'); + const loopCursorKey = pairedCursorKey( + chatJid, + loopIsReviewerTurn, + ); + const rawPendingMessages = getMessagesSinceSeq( chatJid, - deps.getLastAgentTimestamps()[chatJid] || '0', + deps.getLastAgentTimestamps()[loopCursorKey] || '0', deps.assistantName, ); const pendingMessages = filterLoopingPairedBotMessages( @@ -947,6 +966,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { deps.saveState, chatJid, endSeq, + loopCursorKey, ); } logger.debug(