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:
h z
2026-04-09 15:50:19 +01:00
parent 9e61af4a16
commit d8ac9ee0f9
3 changed files with 184 additions and 98 deletions

View File

@@ -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. */