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

@@ -1,8 +1,8 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { isCurrentSpeaker, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { getLatestMessageId, sendAndDelete } from "../core/moderator-discord.js";
import { isCurrentSpeaker, isTurnPending, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { getLatestMessageId, sendScheduleTrigger } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
@@ -16,9 +16,10 @@ type Deps = {
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
noReplyModel: string;
noReplyProvider: string;
scheduleIdentifier: string;
debugMode: boolean;
noReplyProvider: string;
noReplyModel: string;
};
/**
@@ -34,9 +35,7 @@ if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
export function registerBeforeModelResolveHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, noReplyModel, noReplyProvider, scheduleIdentifier } = deps;
const NO_REPLY = { model: noReplyModel, provider: noReplyProvider, noReply: true } as const;
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, noReplyProvider, noReplyModel } = deps;
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
const initializingChannels = getInitializingChannels();
@@ -57,19 +56,23 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
const mode = channelStore.getMode(channelId);
// dead mode: suppress all responses
if (mode === "report" || mode === "dead" as string) return NO_REPLY;
// concluded discussion: suppress all agent responses (auto-reply handled by message_received)
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded) return NO_REPLY;
// dead/report mode: suppress all via no-reply model
if (mode === "report" || mode === "dead" as string) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// disabled modes: let agents respond freely
// concluded discussion: suppress via no-reply model
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
}
// disabled modes: no turn management
if (mode === "none" || mode === "work") return;
// discussion / chat: check turn
// chat / discussion (active): check turn
const agentId = ctx.agentId;
if (!agentId) return;
@@ -78,7 +81,9 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
return NO_REPLY;
// incrementBlockedPending so agent_end knows to expect a stale NO_REPLY completion later
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
initializingChannels.add(channelId);
try {
@@ -95,12 +100,13 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
// If this agent is NOT the first speaker, trigger first speaker and suppress this one
// If this agent is NOT the first speaker, trigger first speaker and suppress self
if (first.agentId !== agentId && moderatorBotToken) {
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
return NO_REPLY;
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// If this agent IS the first speaker, fall through to normal turn logic
// Fall through — this agent IS the first speaker
} else {
// No registered agents visible — let everyone respond freely
return;
@@ -113,13 +119,25 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
}
}
// If channel is dormant: suppress all agents
if (isDormant(channelId)) return NO_REPLY;
// Channel is dormant: suppress via no-reply model
if (isDormant(channelId)) {
api.logger.info(`dirigent: before_model_resolve suppressing dormant agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
if (!isCurrentSpeaker(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`);
api.logger.info(`dirigent: before_model_resolve suppressing non-speaker agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return NO_REPLY;
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// If a turn is already in progress for this agent, this is a duplicate wakeup
// (e.g. agent woke itself via a message-tool send). Suppress it.
if (isTurnPending(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve turn already in progress, suppressing self-wakeup agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)