fix: correct dormancy detection — isEmptyTurn last-line + blocked_pending drain
Two bugs that prevented turn-manager dormancy from ever triggering: 1. isEmptyTurn too strict: agents output multi-line text ending with "NO_REPLY" on the last line, but the regex ^NO_REPLY$ required the entire string to match. Now checks only the last non-empty line. 2. blocked_pending counter inflation: non-speaker suppressions incremented the counter but their stale NO_REPLYs were discarded at the !isCurrentSpeaker early return without decrementing. Over a full cycle the counter inflated by the number of suppressions, causing the agent's real empty turn to be misidentified as stale when it finally arrived. Fix: at both early-return points in agent_end (!isCurrentSpeaker and !isTurnPending), drain blocked_pending when the turn is empty. Also fixed: pollForTailMatch now uses any-message detection (instead of tail-fingerprint content matching) with a 30 s timeout, avoiding infinite polling when agents send concise Discord messages after verbose LLM output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -86,19 +86,45 @@ export async function deleteMessage(
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a message then immediately delete it (used for schedule_identifier trigger). */
|
||||
export async function sendAndDelete(
|
||||
/**
|
||||
* Per-channel last schedule-trigger message ID.
|
||||
* Stored on globalThis so it survives VM-context hot-reloads.
|
||||
* Used by sendScheduleTrigger to delete the PREVIOUS trigger when a new one is sent.
|
||||
*/
|
||||
const _LAST_TRIGGER_KEY = "_dirigentLastTriggerMsgId";
|
||||
if (!(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] = new Map<string, string>();
|
||||
}
|
||||
const lastTriggerMsgId: Map<string, string> = (globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] as Map<string, string>;
|
||||
|
||||
/**
|
||||
* Send a schedule-identifier trigger message, then delete the PREVIOUS one for
|
||||
* this channel (so only the current trigger is visible at any time).
|
||||
*
|
||||
* In debugMode the previous message is NOT deleted, leaving a full trigger
|
||||
* history visible in Discord for inspection.
|
||||
*/
|
||||
export async function sendScheduleTrigger(
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: Logger,
|
||||
debugMode = false,
|
||||
): Promise<void> {
|
||||
const prevMsgId = lastTriggerMsgId.get(channelId);
|
||||
const result = await sendModeratorMessage(token, channelId, content, logger);
|
||||
if (result.ok && result.messageId) {
|
||||
// Small delay to ensure Discord has processed the message before deletion
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await deleteMessage(token, channelId, result.messageId, logger);
|
||||
if (!result.ok || !result.messageId) return;
|
||||
|
||||
if (!debugMode) {
|
||||
// Track the new message so the NEXT call can delete it
|
||||
lastTriggerMsgId.set(channelId, result.messageId);
|
||||
// Delete the previous trigger with a small delay
|
||||
if (prevMsgId) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await deleteMessage(token, channelId, prevMsgId, logger);
|
||||
}
|
||||
}
|
||||
// debugMode: don't track, don't delete — every trigger stays in history
|
||||
}
|
||||
|
||||
/** Get the latest message ID in a channel (for use as poll anchor). */
|
||||
@@ -121,32 +147,45 @@ export async function getLatestMessageId(
|
||||
type DiscordMessage = { id: string; author: { id: string }; content: string };
|
||||
|
||||
/**
|
||||
* Poll the channel until a message from agentDiscordUserId with id > anchorId
|
||||
* ends with the tail fingerprint.
|
||||
* Poll the channel until any message from agentDiscordUserId with id > anchorId
|
||||
* appears. The anchor is set in before_model_resolve just before the LLM call,
|
||||
* so any agent message after it must belong to this turn.
|
||||
*
|
||||
* @returns the matching messageId, or undefined on timeout.
|
||||
* Uses exponential back-off starting at initialPollMs, doubling each miss,
|
||||
* capped at maxPollMs. Times out after timeoutMs (default 30 s) to avoid
|
||||
* getting permanently stuck when the agent's Discord message is never delivered.
|
||||
*
|
||||
* @returns { matched: true } when found, { interrupted: true } when aborted,
|
||||
* { matched: false, interrupted: false } on timeout.
|
||||
*/
|
||||
export async function pollForTailMatch(opts: {
|
||||
token: string;
|
||||
channelId: string;
|
||||
anchorId: string;
|
||||
agentDiscordUserId: string;
|
||||
tailFingerprint: string;
|
||||
/** Initial poll interval in ms (default 800). */
|
||||
initialPollMs?: number;
|
||||
/** Maximum poll interval in ms (default 8 000). */
|
||||
maxPollMs?: number;
|
||||
/** Give up and return after this many ms (default 30 000). */
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
/** Callback checked each poll; if true, polling is aborted (interrupted). */
|
||||
/** Callback checked before each poll; if true, polling is aborted. */
|
||||
isInterrupted?: () => boolean;
|
||||
}): Promise<{ matched: boolean; interrupted: boolean }> {
|
||||
const {
|
||||
token, channelId, anchorId, agentDiscordUserId,
|
||||
tailFingerprint, timeoutMs = 15000, pollIntervalMs = 800,
|
||||
initialPollMs = 800,
|
||||
maxPollMs = 8_000,
|
||||
timeoutMs = 30_000,
|
||||
isInterrupted = () => false,
|
||||
} = opts;
|
||||
|
||||
let interval = initialPollMs;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
while (true) {
|
||||
if (isInterrupted()) return { matched: false, interrupted: true };
|
||||
if (Date.now() >= deadline) return { matched: false, interrupted: false };
|
||||
|
||||
try {
|
||||
const r = await fetch(
|
||||
@@ -155,25 +194,19 @@ export async function pollForTailMatch(opts: {
|
||||
);
|
||||
if (r.ok) {
|
||||
const msgs = (await r.json()) as DiscordMessage[];
|
||||
const candidates = msgs.filter(
|
||||
const found = msgs.some(
|
||||
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
// Most recent is first in Discord's response
|
||||
const latest = candidates[0];
|
||||
if (latest.content.endsWith(tailFingerprint)) {
|
||||
return { matched: true, interrupted: false };
|
||||
}
|
||||
}
|
||||
if (found) return { matched: true, interrupted: false };
|
||||
}
|
||||
} catch {
|
||||
// ignore transient errors, keep polling
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
// Exponential back-off, capped at maxPollMs
|
||||
interval = Math.min(interval * 2, maxPollMs);
|
||||
}
|
||||
|
||||
return { matched: false, interrupted: false };
|
||||
}
|
||||
|
||||
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
|
||||
|
||||
Reference in New Issue
Block a user