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>
142 lines
6.1 KiB
TypeScript
142 lines
6.1 KiB
TypeScript
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 { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
|
|
|
/** 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 Deps = {
|
|
api: OpenClawPluginApi;
|
|
channelStore: ChannelStore;
|
|
identityRegistry: IdentityRegistry;
|
|
moderatorBotToken: string | undefined;
|
|
noReplyModel: string;
|
|
noReplyProvider: string;
|
|
scheduleIdentifier: string;
|
|
};
|
|
|
|
/**
|
|
* 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) => {
|
|
// 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 sessionKey = ctx.sessionKey;
|
|
if (!sessionKey) return;
|
|
|
|
// Only handle Discord group channel sessions
|
|
const channelId = parseDiscordChannelId(sessionKey);
|
|
if (!channelId) return;
|
|
|
|
const mode = channelStore.getMode(channelId);
|
|
|
|
// dead mode: suppress all responses
|
|
if (mode === "report" || mode === "dead" as string) return NO_REPLY;
|
|
|
|
// disabled modes: let agents respond freely
|
|
if (mode === "none" || mode === "work") return;
|
|
|
|
// discussion / chat: check turn
|
|
const agentId = ctx.agentId;
|
|
if (!agentId) return;
|
|
|
|
// 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;
|
|
}
|
|
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);
|
|
|
|
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 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;
|
|
}
|
|
} catch (err) {
|
|
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
|
|
return;
|
|
} finally {
|
|
initializingChannels.delete(channelId);
|
|
}
|
|
}
|
|
|
|
// If channel is dormant: suppress all agents
|
|
if (isDormant(channelId)) return NO_REPLY;
|
|
|
|
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`);
|
|
}
|
|
});
|
|
}
|