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.
This commit is contained in:
129
plugin/index.ts
129
plugin/index.ts
@@ -28,6 +28,7 @@ const sessionAllowed = new Map<string, boolean>(); // Track if session was allow
|
||||
const sessionInjected = new Set<string>(); // Track which sessions have already injected the end marker
|
||||
const sessionChannelId = new Map<string, string>(); // Track sessionKey -> channelId mapping
|
||||
const sessionAccountId = new Map<string, string>(); // Track sessionKey -> accountId mapping
|
||||
const sessionTurnHandled = new Set<string>(); // 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";
|
||||
|
||||
Reference in New Issue
Block a user