From 6d17e6a911cc7f95ec8209b7a012fa14205d924b Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 2 Mar 2026 11:04:41 +0000 Subject: [PATCH 1/2] fix: advance turn in before_message_write to prevent race condition When a speaker finishes with an end symbol, the turn was only advanced in the message_sent hook. But by that time, the message had already been broadcast to other agents, whose before_model_resolve ran with the old turn state, causing them to be blocked by the turn gate (forced no-reply). Fix: Move turn advance for both NO_REPLY and end-symbol cases to before_message_write, which fires before the message is broadcast. Use sessionTurnHandled set to prevent double-advancing in message_sent. --- dist/whispergate/index.ts | 129 +++++++++++++++++++++++--------------- plugin/index.ts | 129 +++++++++++++++++++++++--------------- 2 files changed, 158 insertions(+), 100 deletions(-) diff --git a/dist/whispergate/index.ts b/dist/whispergate/index.ts index 61c61ad..a4b507d 100644 --- a/dist/whispergate/index.ts +++ b/dist/whispergate/index.ts @@ -28,6 +28,7 @@ const sessionAllowed = new Map(); // Track if session was allow const sessionInjected = new Set(); // Track which sessions have already injected the end marker const sessionChannelId = new Map(); // Track sessionKey -> channelId mapping const sessionAccountId = new Map(); // Track sessionKey -> accountId mapping +const sessionTurnHandled = new Set(); // Track sessions where turn was already advanced in before_message_write const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -960,9 +961,15 @@ export default { if (!key || !channelId || !accountId) return; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + const policy = resolvePolicy(live, channelId, policyState.channelPolicies); const trimmed = content.trim(); - const isNoReply = /^NO_REPLY$/i.test(trimmed); + const isEmpty = trimmed.length === 0; + const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); + const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; + const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); + const wasNoReply = isEmpty || isNoReply; // Log turn state for debugging const turnDebug = getTurnDebugInfo(channelId); @@ -970,62 +977,75 @@ export default { `whispergate: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`, ); - if (!isNoReply) { - api.logger.info( - `whispergate: before_message_write content is not NO_REPLY, skipping channel=${channelId}`, - ); - return; - } - // Check if this session was forced no-reply or allowed to speak const wasAllowed = sessionAllowed.get(key); - api.logger.info( - `whispergate: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, - ); - if (wasAllowed === undefined) return; // No record, skip - - if (wasAllowed === false) { - // Forced no-reply - do not advance turn - sessionAllowed.delete(key); + if (wasNoReply) { api.logger.info( - `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, + `whispergate: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, + ); + + if (wasAllowed === undefined) return; // No record, skip + + if (wasAllowed === false) { + // Forced no-reply - do not advance turn + sessionAllowed.delete(key); + api.logger.info( + `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, + ); + return; + } + + // Allowed to speak (current speaker) but chose NO_REPLY - advance turn + ensureTurnOrder(api, channelId, live); + const nextSpeaker = onSpeakerDone(channelId, accountId, true); + sessionAllowed.delete(key); + sessionTurnHandled.add(key); + + api.logger.info( + `whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + ); + + // If all agents NO_REPLY'd (dormant), don't trigger handoff + if (!nextSpeaker) { + if (shouldDebugLog(live, channelId)) { + api.logger.info( + `whispergate: before_message_write all agents no-reply, going dormant - no handoff`, + ); + } + return; + } + + // Trigger moderator handoff message (fire-and-forget, don't await) + if (live.moderatorBotToken) { + const nextUserId = resolveDiscordUserId(api, nextSpeaker); + if (nextUserId) { + const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; + void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { + api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`); + }); + } else { + api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); + } + } + } else if (hasEndSymbol) { + // End symbol detected — advance turn NOW (before message is broadcast to other agents) + // This prevents the race condition where other agents receive the message + // before message_sent fires and advances the turn. + ensureTurnOrder(api, channelId, live); + const nextSpeaker = onSpeakerDone(channelId, accountId, false); + sessionAllowed.delete(key); + sessionTurnHandled.add(key); + + api.logger.info( + `whispergate: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + ); + } else { + api.logger.info( + `whispergate: before_message_write no turn action needed session=${key} channel=${channelId}`, ); return; } - - // Allowed to speak (current speaker) but chose NO_REPLY - advance turn - ensureTurnOrder(api, channelId); - const nextSpeaker = onSpeakerDone(channelId, accountId, true); - - sessionAllowed.delete(key); - - api.logger.info( - `whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, - ); - - // If all agents NO_REPLY'd (dormant), don't trigger handoff - if (!nextSpeaker) { - if (shouldDebugLog(live, channelId)) { - api.logger.info( - `whispergate: before_message_write all agents no-reply, going dormant - no handoff`, - ); - } - return; - } - - // Trigger moderator handoff message (fire-and-forget, don't await) - if (live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; - void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { - api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`); - }); - } else { - api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); - } - } } catch (err) { api.logger.warn(`whispergate: before_message_write hook failed: ${String(err)}`); } @@ -1079,6 +1099,15 @@ export default { const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const wasNoReply = isEmpty || isNoReply; + // Skip if turn was already advanced in before_message_write + if (key && sessionTurnHandled.has(key)) { + sessionTurnHandled.delete(key); + api.logger.info( + `whispergate: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`, + ); + return; + } + if (wasNoReply || hasEndSymbol) { const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol"; diff --git a/plugin/index.ts b/plugin/index.ts index 61c61ad..a4b507d 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -28,6 +28,7 @@ const sessionAllowed = new Map(); // Track if session was allow const sessionInjected = new Set(); // Track which sessions have already injected the end marker const sessionChannelId = new Map(); // Track sessionKey -> channelId mapping const sessionAccountId = new Map(); // Track sessionKey -> accountId mapping +const sessionTurnHandled = new Set(); // Track sessions where turn was already advanced in before_message_write const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -960,9 +961,15 @@ export default { if (!key || !channelId || !accountId) return; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + const policy = resolvePolicy(live, channelId, policyState.channelPolicies); const trimmed = content.trim(); - const isNoReply = /^NO_REPLY$/i.test(trimmed); + const isEmpty = trimmed.length === 0; + const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); + const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; + const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); + const wasNoReply = isEmpty || isNoReply; // Log turn state for debugging const turnDebug = getTurnDebugInfo(channelId); @@ -970,62 +977,75 @@ export default { `whispergate: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`, ); - if (!isNoReply) { - api.logger.info( - `whispergate: before_message_write content is not NO_REPLY, skipping channel=${channelId}`, - ); - return; - } - // Check if this session was forced no-reply or allowed to speak const wasAllowed = sessionAllowed.get(key); - api.logger.info( - `whispergate: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, - ); - if (wasAllowed === undefined) return; // No record, skip - - if (wasAllowed === false) { - // Forced no-reply - do not advance turn - sessionAllowed.delete(key); + if (wasNoReply) { api.logger.info( - `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, + `whispergate: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, + ); + + if (wasAllowed === undefined) return; // No record, skip + + if (wasAllowed === false) { + // Forced no-reply - do not advance turn + sessionAllowed.delete(key); + api.logger.info( + `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, + ); + return; + } + + // Allowed to speak (current speaker) but chose NO_REPLY - advance turn + ensureTurnOrder(api, channelId, live); + const nextSpeaker = onSpeakerDone(channelId, accountId, true); + sessionAllowed.delete(key); + sessionTurnHandled.add(key); + + api.logger.info( + `whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + ); + + // If all agents NO_REPLY'd (dormant), don't trigger handoff + if (!nextSpeaker) { + if (shouldDebugLog(live, channelId)) { + api.logger.info( + `whispergate: before_message_write all agents no-reply, going dormant - no handoff`, + ); + } + return; + } + + // Trigger moderator handoff message (fire-and-forget, don't await) + if (live.moderatorBotToken) { + const nextUserId = resolveDiscordUserId(api, nextSpeaker); + if (nextUserId) { + const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; + void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { + api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`); + }); + } else { + api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); + } + } + } else if (hasEndSymbol) { + // End symbol detected — advance turn NOW (before message is broadcast to other agents) + // This prevents the race condition where other agents receive the message + // before message_sent fires and advances the turn. + ensureTurnOrder(api, channelId, live); + const nextSpeaker = onSpeakerDone(channelId, accountId, false); + sessionAllowed.delete(key); + sessionTurnHandled.add(key); + + api.logger.info( + `whispergate: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + ); + } else { + api.logger.info( + `whispergate: before_message_write no turn action needed session=${key} channel=${channelId}`, ); return; } - - // Allowed to speak (current speaker) but chose NO_REPLY - advance turn - ensureTurnOrder(api, channelId); - const nextSpeaker = onSpeakerDone(channelId, accountId, true); - - sessionAllowed.delete(key); - - api.logger.info( - `whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, - ); - - // If all agents NO_REPLY'd (dormant), don't trigger handoff - if (!nextSpeaker) { - if (shouldDebugLog(live, channelId)) { - api.logger.info( - `whispergate: before_message_write all agents no-reply, going dormant - no handoff`, - ); - } - return; - } - - // Trigger moderator handoff message (fire-and-forget, don't await) - if (live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; - void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { - api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`); - }); - } else { - api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); - } - } } catch (err) { api.logger.warn(`whispergate: before_message_write hook failed: ${String(err)}`); } @@ -1079,6 +1099,15 @@ export default { const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const wasNoReply = isEmpty || isNoReply; + // Skip if turn was already advanced in before_message_write + if (key && sessionTurnHandled.has(key)) { + sessionTurnHandled.delete(key); + api.logger.info( + `whispergate: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`, + ); + return; + } + if (wasNoReply || hasEndSymbol) { const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol"; -- 2.49.1 From cf9be6014524c66a09129e328bf3652d4e85b1cc Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 2 Mar 2026 11:20:39 +0000 Subject: [PATCH 2/2] fix: advance turn in before_message_write to prevent race condition When a speaker finishes with an end symbol, the turn was only advanced in the message_sent hook. But by that time, the message had already been broadcast to other agents, whose before_model_resolve ran with the old turn state, causing them to be blocked by the turn gate (forced no-reply). Fix: - Move turn advance for both NO_REPLY and end-symbol cases to before_message_write, which fires before the message is broadcast. - Guard 1: Only the current speaker can advance the turn (accountId check). - Guard 2: Only process assistant messages (role check). before_message_write fires for incoming user messages too, which contain end symbols from other agents and would cause cascading turn advances. - Use sessionTurnHandled set to prevent double-advancing in message_sent. --- dist/whispergate/index.ts | 16 ++++++++++++++++ plugin/index.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/dist/whispergate/index.ts b/dist/whispergate/index.ts index a4b507d..d838633 100644 --- a/dist/whispergate/index.ts +++ b/dist/whispergate/index.ts @@ -932,9 +932,14 @@ export default { } // Extract content from event.message (AgentMessage) + // Only process assistant messages — before_message_write fires for both + // user (incoming) and assistant (outgoing) messages. Incoming messages may + // contain end symbols from OTHER agents, which would incorrectly advance the turn. let content = ""; const msg = (event as Record).message as Record | undefined; if (msg) { + const role = msg.role as string | undefined; + if (role && role !== "assistant") return; // AgentMessage may have content as string or nested if (typeof msg.content === "string") { content = msg.content; @@ -960,6 +965,17 @@ export default { if (!key || !channelId || !accountId) return; + // Only the current speaker should advance the turn. + // Other agents also trigger before_message_write (for incoming messages or forced no-reply), + // but they must not affect turn state. + const currentTurn = getTurnDebugInfo(channelId); + if (currentTurn.currentSpeaker !== accountId) { + api.logger.info( + `whispergate: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`, + ); + return; + } + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies); diff --git a/plugin/index.ts b/plugin/index.ts index a4b507d..d838633 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -932,9 +932,14 @@ export default { } // Extract content from event.message (AgentMessage) + // Only process assistant messages — before_message_write fires for both + // user (incoming) and assistant (outgoing) messages. Incoming messages may + // contain end symbols from OTHER agents, which would incorrectly advance the turn. let content = ""; const msg = (event as Record).message as Record | undefined; if (msg) { + const role = msg.role as string | undefined; + if (role && role !== "assistant") return; // AgentMessage may have content as string or nested if (typeof msg.content === "string") { content = msg.content; @@ -960,6 +965,17 @@ export default { if (!key || !channelId || !accountId) return; + // Only the current speaker should advance the turn. + // Other agents also trigger before_message_write (for incoming messages or forced no-reply), + // but they must not affect turn state. + const currentTurn = getTurnDebugInfo(channelId); + if (currentTurn.currentSpeaker !== accountId) { + api.logger.info( + `whispergate: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`, + ); + return; + } + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies); -- 2.49.1