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,176 +1,141 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js";
import { checkTurn } from "../turn-manager.js";
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
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 { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
export function parseDiscordChannelId(sessionKey: string): string | undefined {
const m = sessionKey.match(/:discord:channel:(\d+)$/);
return m?.[1];
}
type DecisionRecord = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
type BeforeModelResolveDeps = {
type Deps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
sessionDecision: Map<string, DecisionRecord>;
sessionAllowed: Map<string, boolean>;
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
recordDiscussionSession?: (channelId: string, sessionKey: string) => void;
forceNoReplySessions: Set<string>;
policyState: { channelPolicies: Record<string, unknown> };
DECISION_TTL_MS: number;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
pruneDecisionMap: () => void;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
isMultiMessageMode: (channelId: string) => boolean;
discussionService?: {
isClosedDiscussion: (channelId: string) => boolean;
};
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
noReplyModel: string;
noReplyProvider: string;
scheduleIdentifier: string;
};
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
const {
api,
baseConfig,
sessionDecision,
sessionAllowed,
sessionChannelId,
sessionAccountId,
recordDiscussionSession,
forceNoReplySessions,
policyState,
DECISION_TTL_MS,
ensurePolicyStateLoaded,
resolveAccountId,
pruneDecisionMap,
shouldDebugLog,
ensureTurnOrder,
isMultiMessageMode,
discussionService,
} = deps;
/**
* Process-level deduplication for before_model_resolve events.
* Uses a WeakSet keyed on the event object — works when OpenClaw passes
* the same event reference to all stacked handlers (hot-reload scenario).
* Stored on globalThis so it persists across module reloads.
*/
const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents";
if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] = new WeakSet<object>();
}
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;
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
const initializingChannels = getInitializingChannels();
api.on("before_model_resolve", async (event, ctx) => {
const key = ctx.sessionKey;
if (!key) return;
// Deduplicate: if another handler instance already processed this event
// object, skip. Prevents double-counting from hot-reload stacked handlers.
const eventObj = event as object;
if (processedBeforeModelResolveEvents.has(eventObj)) return;
processedBeforeModelResolveEvents.add(eventObj);
const live = baseConfig as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
const sessionKey = ctx.sessionKey;
if (!sessionKey) return;
if (forceNoReplySessions.has(key)) {
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
}
// Only handle Discord group channel sessions
const channelId = parseDiscordChannelId(sessionKey);
if (!channelId) return;
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
const mode = channelStore.getMode(channelId);
if (live.enableDebugLogs) {
api.logger.info(
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
`promptPreview=${prompt.slice(0, 300)}`,
);
}
// dead mode: suppress all responses
if (mode === "report" || mode === "dead" as string) return NO_REPLY;
const derived = deriveDecisionInputFromPrompt({
prompt,
messageProvider: ctx.messageProvider,
sessionKey: key,
ctx: ctx as Record<string, unknown>,
event: event as Record<string, unknown>,
});
// disabled modes: let agents respond freely
if (mode === "none" || mode === "work") return;
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
// discussion / chat: check turn
const agentId = ctx.agentId;
if (!agentId) return;
if (derived.channelId) {
sessionChannelId.set(key, derived.channelId);
recordDiscussionSession?.(derived.channelId, key);
if (discussionService?.isClosedDiscussion(derived.channelId)) {
sessionAllowed.set(key, false);
api.logger.info(`dirigent: before_model_resolve forcing no-reply for closed discussion channel=${derived.channelId} session=${key}`);
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
// If speaker list not yet loaded, initialize it now
if (!hasSpeakers(channelId)) {
// 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;
}
if (isMultiMessageMode(derived.channelId)) {
sessionAllowed.set(key, false);
api.logger.info(`dirigent: before_model_resolve forcing no-reply for multi-message mode channel=${derived.channelId} session=${key}`);
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
}
}
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
if (resolvedAccountId) {
sessionAccountId.set(key, resolvedAccountId);
}
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);
let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key);
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies as Record<string, any>,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
sessionDecision.set(key, rec);
pruneDecisionMap();
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`dirigent: debug before_model_resolve recompute session=${key} ` +
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
}
}
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
if (derived.channelId) {
await ensureTurnOrder(api, derived.channelId);
const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId);
if (!turnCheck.allowed) {
sessionAllowed.set(key, false);
api.logger.info(
`dirigent: before_model_resolve blocking out-of-turn speaker session=${key} channel=${derived.channelId} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker}`,
);
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
// If this agent is NOT the first speaker, trigger first speaker and suppress this one
if (first.agentId !== agentId && moderatorBotToken) {
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
return NO_REPLY;
}
// If this agent IS the first speaker, fall through to normal turn logic
} else {
// No registered agents visible — let everyone respond freely
return;
}
sessionAllowed.set(key, true);
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
return;
} finally {
initializingChannels.delete(channelId);
}
}
if (!rec.decision.shouldUseNoReply) return;
// If channel is dormant: suppress all agents
if (isDormant(channelId)) return NO_REPLY;
const out: Record<string, unknown> = { noReply: true };
if (rec.decision.provider) out.provider = rec.decision.provider;
if (rec.decision.model) out.model = rec.decision.model;
return out;
if (!isCurrentSpeaker(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return NO_REPLY;
}
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
markTurnStarted(channelId, agentId);
// Current speaker: record anchor message ID for tail-match polling
if (moderatorBotToken) {
try {
const anchorId = await getLatestMessageId(moderatorBotToken, channelId);
if (anchorId) {
setAnchor(channelId, agentId, anchorId);
api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`);
}
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`);
}
}
// Verify agent has a known Discord user ID (needed for tail-match later)
const identity = identityRegistry.findByAgentId(agentId);
if (!identity) {
api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`);
}
});
}