feat: rewrite plugin as v2 with globalThis-based turn management

Complete rewrite of the Dirigent plugin turn management system to work
correctly with OpenClaw's VM-context-per-session architecture:

- All turn state stored on globalThis (persists across VM context hot-reloads)
- Hooks registered unconditionally on every api instance; event-level dedup
  (runId Set for agent_end, WeakSet for before_model_resolve) prevents
  double-processing
- Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag
- Shared initializingChannels lock prevents concurrent channel init across VM
  contexts in message_received and before_model_resolve
- New ChannelStore and IdentityRegistry replace old policy/session-state modules
- Added agent_end hook with tail-match polling for Discord delivery confirmation
- Added web control page, padded-cell auto-scan, discussion tool support
- Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service,
  session-state, turn-bootstrap, policy/store, rules, decision-input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-08 22:41:25 +01:00
parent dea345698b
commit b5196e972c
40 changed files with 2427 additions and 2753 deletions

View File

@@ -1,141 +1,128 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js";
import { extractDiscordChannelId } from "../channel-resolver.js";
import { isDiscussionOriginCallbackMessage } from "../core/discussion-messages.js";
import type { DirigentConfig } from "../rules.js";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { parseDiscordChannelId } from "./before-model-resolve.js";
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { sendAndDelete, sendModeratorMessage } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
import type { InterruptFn } from "./agent-end.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
type MessageReceivedDeps = {
type Deps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
debugCtxSummary: (ctx: Record<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
getModeratorUserId: (cfg: DirigentConfig) => string | undefined;
recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean;
extractMentionedUserIds: (content: string) => string[];
buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map<string, string>;
enterMultiMessageMode: (channelId: string) => void;
exitMultiMessageMode: (channelId: string) => void;
discussionService?: {
maybeReplyClosedChannel: (channelId: string, senderId?: string) => Promise<boolean>;
};
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
interruptTailMatch: InterruptFn;
};
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void {
const {
api,
baseConfig,
shouldDebugLog,
debugCtxSummary,
ensureTurnOrder,
getModeratorUserId,
recordChannelAccount,
extractMentionedUserIds,
buildUserIdToAccountIdMap,
enterMultiMessageMode,
exitMultiMessageMode,
discussionService,
} = deps;
export function registerMessageReceivedHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps;
api.on("message_received", async (event, ctx) => {
try {
const c = (ctx || {}) as Record<string, unknown>;
const e = (event || {}) as Record<string, unknown>;
const preChannelId = extractDiscordChannelId(c, e);
const livePre = baseConfig as DirigentConfig & DebugConfig;
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
const e = event as Record<string, unknown>;
const c = ctx as Record<string, unknown>;
// Extract Discord channel ID from session key or event metadata
let channelId: string | undefined;
if (typeof c.sessionKey === "string") {
channelId = parseDiscordChannelId(c.sessionKey);
}
if (!channelId) {
// Try from event metadata (conversation_info channel_id field)
const metadata = e.metadata as Record<string, unknown> | undefined;
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
const raw = String(convInfo?.channel_id ?? metadata?.channelId ?? "");
if (/^\d+$/.test(raw)) channelId = raw;
}
if (!channelId) return;
const mode = channelStore.getMode(channelId);
// dead: suppress routing entirely (OpenClaw handles no-route automatically,
// but we handle archived auto-reply here)
if (mode === "report") return;
// archived: auto-reply via moderator
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded && moderatorBotToken) {
await sendModeratorMessage(
moderatorBotToken, channelId,
"This discussion is closed and no longer active.",
api.logger,
).catch(() => undefined);
return;
}
}
if (preChannelId) {
await ensureTurnOrder(api, preChannelId);
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
const from =
(typeof metadata?.senderId === "string" && metadata.senderId) ||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
if (mode === "none" || mode === "work") return;
const moderatorUserId = getModeratorUserId(livePre);
if (discussionService) {
const closedHandled = await discussionService.maybeReplyClosedChannel(preChannelId, from);
if (closedHandled) return;
// chat / discussion (active): initialize speaker list on first message if needed
const initializingChannels = getInitializingChannels();
if (!hasSpeakers(channelId) && moderatorBotToken) {
// Guard against concurrent initialization from multiple VM contexts
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
return;
}
initializingChannels.add(channelId);
try {
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const speakers: SpeakerEntry[] = agentIds
.map((aid) => {
const entry = identityRegistry.findByAgentId(aid);
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
})
.filter((s): s is SpeakerEntry => s !== null);
const messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || "";
const isModeratorOriginCallback = !!(moderatorUserId && from === moderatorUserId && isDiscussionOriginCallbackMessage(messageContent));
if (moderatorUserId && from === moderatorUserId && !isModeratorOriginCallback) {
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`);
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`);
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
return;
}
} else {
const humanList = livePre.humanList || livePre.bypassUserIds || [];
const isHuman = humanList.includes(from);
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
} finally {
initializingChannels.delete(channelId);
}
}
if (senderAccountId && senderAccountId !== "default") {
const isNew = recordChannelAccount(api, preChannelId, senderAccountId);
if (isNew) {
await ensureTurnOrder(api, preChannelId);
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
}
}
// chat / discussion (active): check if this is an external message
// that should interrupt an in-progress tail-match or wake dormant
if (isHuman) {
const startMarker = livePre.multiMessageStartMarker || "↗️";
const endMarker = livePre.multiMessageEndMarker || "↙️";
const senderId = String(
(e.metadata as Record<string, unknown>)?.senderId ??
(e.metadata as Record<string, unknown>)?.sender_id ??
e.from ?? "",
);
if (messageContent.includes(startMarker)) {
enterMultiMessageMode(preChannelId);
api.logger.info(`dirigent: entered multi-message mode channel=${preChannelId}`);
} else if (messageContent.includes(endMarker)) {
exitMultiMessageMode(preChannelId);
api.logger.info(`dirigent: exited multi-message mode channel=${preChannelId}`);
onNewMessage(preChannelId, senderAccountId, isHuman);
} else {
const mentionedUserIds = extractMentionedUserIds(messageContent);
// Identify the sender: is it the current speaker's Discord account?
const currentSpeakerIsThisSender = (() => {
if (!senderId) return false;
const entry = identityRegistry.findByDiscordUserId(senderId);
if (!entry) return false;
return isCurrentSpeaker(channelId!, entry.agentId);
})();
if (mentionedUserIds.length > 0) {
const userIdMap = buildUserIdToAccountIdMap(api);
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid);
if (!currentSpeakerIsThisSender) {
// Non-current-speaker posted — interrupt any tail-match in progress
interruptTailMatch(channelId);
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
if (mentionedAccountIds.length > 0) {
await ensureTurnOrder(api, preChannelId);
const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds);
if (overrideSet) {
api.logger.info(
`dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`,
);
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`);
}
} else {
onNewMessage(preChannelId, senderAccountId, isHuman);
}
} else {
onNewMessage(preChannelId, senderAccountId, isHuman);
}
} else {
onNewMessage(preChannelId, senderAccountId, isHuman);
}
}
} else {
onNewMessage(preChannelId, senderAccountId, false);
}
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`,
);
// Wake from dormant if needed
if (isDormant(channelId) && moderatorBotToken) {
const first = wakeFromDormant(channelId);
if (first) {
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
await sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
}
}
}
} catch (err) {
api.logger.warn(`dirigent: message hook failed: ${String(err)}`);
api.logger.warn(`dirigent: message_received hook error: ${String(err)}`);
}
});
}