From c9ed5ed76bf2a4e7ec06ef1ef239de5b5d56854a Mon Sep 17 00:00:00 2001 From: ZengGanghui Date: Fri, 27 Mar 2026 01:53:13 +0800 Subject: [PATCH] fix: prevent reflection loop with global cross-instance re-entrant guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously each plugin instance maintained its own re-entrant guard Map. When the runtime re-loaded the plugin during embedded agent turns (e.g. command:new inside a reflection), a new instance would bypass the guard, causing infinite reflection loops. This change introduces two global guards using Symbol.for + globalThis so ALL plugin instances share the same state: 1. **Global re-entrant lock** — prevents concurrent reflection calls for the same sessionKey across all plugin instances. 2. **Serial loop guard** — imposes a 2-minute cooldown per sessionKey between consecutive reflection runs, preventing gateway-level re-triggering chains (session_end → new session → command:new). --- index.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/index.ts b/index.ts index 74cd7c6..f30830b 100644 --- a/index.ts +++ b/index.ts @@ -3096,8 +3096,47 @@ const memoryLanceDBProPlugin = { pruneReflectionSessionState(); }, { priority: 20 }); + // Global cross-instance re-entrant guard to prevent reflection loops. + // Each plugin instance used to have its own Map, so new instances created during + // embedded agent turns could bypass the guard. Using Symbol.for + globalThis + // ensures ALL instances share the same lock regardless of how many times the + // plugin is re-loaded by the runtime. + const GLOBAL_REFLECTION_LOCK = Symbol.for("openclaw.memory-lancedb-pro.reflection-lock"); + const getGlobalReflectionLock = (): Map => { + const g = globalThis as Record; + if (!g[GLOBAL_REFLECTION_LOCK]) g[GLOBAL_REFLECTION_LOCK] = new Map(); + return g[GLOBAL_REFLECTION_LOCK] as Map; + }; + + // Serial loop guard: track last reflection time per sessionKey to prevent + // gateway-level re-triggering (e.g. session_end → new session → command:new) + const REFLECTION_SERIAL_GUARD = Symbol.for("openclaw.memory-lancedb-pro.reflection-serial-guard"); + const getSerialGuardMap = () => { + const g = globalThis as any; + if (!g[REFLECTION_SERIAL_GUARD]) g[REFLECTION_SERIAL_GUARD] = new Map(); + return g[REFLECTION_SERIAL_GUARD] as Map; + }; + const SERIAL_GUARD_COOLDOWN_MS = 120_000; // 2 minutes cooldown per sessionKey + const runMemoryReflection = async (event: any) => { const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; + // Guard against re-entrant calls for the same session (e.g. file-write triggering another command:new) + // Uses global lock shared across all plugin instances to prevent loop amplification. + const globalLock = getGlobalReflectionLock(); + if (sessionKey && globalLock.get(sessionKey)) { + api.logger.info(`memory-reflection: skipping re-entrant call for sessionKey=${sessionKey}; already running (global guard)`); + return; + } + // Serial loop guard: skip if a reflection for this sessionKey completed recently + if (sessionKey) { + const serialGuard = getSerialGuardMap(); + const lastRun = serialGuard.get(sessionKey); + if (lastRun && (Date.now() - lastRun) < SERIAL_GUARD_COOLDOWN_MS) { + api.logger.info(`memory-reflection: skipping serial re-trigger for sessionKey=${sessionKey}; last run ${(Date.now() - lastRun) / 1000}s ago (cooldown=${SERIAL_GUARD_COOLDOWN_MS / 1000}s)`); + return; + } + } + if (sessionKey) globalLock.set(sessionKey, true); try { pruneReflectionSessionState(); const action = String(event?.action || "unknown"); @@ -3357,6 +3396,8 @@ const memoryLanceDBProPlugin = { } finally { if (sessionKey) { reflectionErrorStateBySession.delete(sessionKey); + getGlobalReflectionLock().delete(sessionKey); + getSerialGuardMap().set(sessionKey, Date.now()); } pruneReflectionSessionState(); }