Skip to content

Commit 4878abd

Browse files
ggzengZengGanghui
andauthored
fix: prevent reflection loop with global cross-instance re-entrant guard (#369)
* fix: prevent reflection loop with global cross-instance re-entrant guard 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). * fix: 仅在 reflection 实际运行后记录 serial cooldown 将 cooldown 时间戳的记录从 finally 无条件执行改为仅当 reflection 通过所有前置条件检查(cfg、session file、conversation)后才记录。 避免因前置条件不满足导致的提前退出误触 cooldown,阻塞后续正常重试。 回应 @rwmjhb#369 的审查反馈。 --------- Co-authored-by: ZengGanghui <zgh@stsctech.com>
1 parent 96ef2dd commit 4878abd

File tree

1 file changed

+49
-0
lines changed

1 file changed

+49
-0
lines changed

index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3215,8 +3215,48 @@ const memoryLanceDBProPlugin = {
32153215
pruneReflectionSessionState();
32163216
}, { priority: 20 });
32173217

3218+
// Global cross-instance re-entrant guard to prevent reflection loops.
3219+
// Each plugin instance used to have its own Map, so new instances created during
3220+
// embedded agent turns could bypass the guard. Using Symbol.for + globalThis
3221+
// ensures ALL instances share the same lock regardless of how many times the
3222+
// plugin is re-loaded by the runtime.
3223+
const GLOBAL_REFLECTION_LOCK = Symbol.for("openclaw.memory-lancedb-pro.reflection-lock");
3224+
const getGlobalReflectionLock = (): Map<string, boolean> => {
3225+
const g = globalThis as Record<symbol, unknown>;
3226+
if (!g[GLOBAL_REFLECTION_LOCK]) g[GLOBAL_REFLECTION_LOCK] = new Map<string, boolean>();
3227+
return g[GLOBAL_REFLECTION_LOCK] as Map<string, boolean>;
3228+
};
3229+
3230+
// Serial loop guard: track last reflection time per sessionKey to prevent
3231+
// gateway-level re-triggering (e.g. session_end → new session → command:new)
3232+
const REFLECTION_SERIAL_GUARD = Symbol.for("openclaw.memory-lancedb-pro.reflection-serial-guard");
3233+
const getSerialGuardMap = () => {
3234+
const g = globalThis as any;
3235+
if (!g[REFLECTION_SERIAL_GUARD]) g[REFLECTION_SERIAL_GUARD] = new Map<string, number>();
3236+
return g[REFLECTION_SERIAL_GUARD] as Map<string, number>;
3237+
};
3238+
const SERIAL_GUARD_COOLDOWN_MS = 120_000; // 2 minutes cooldown per sessionKey
3239+
32183240
const runMemoryReflection = async (event: any) => {
32193241
const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : "";
3242+
// Guard against re-entrant calls for the same session (e.g. file-write triggering another command:new)
3243+
// Uses global lock shared across all plugin instances to prevent loop amplification.
3244+
const globalLock = getGlobalReflectionLock();
3245+
if (sessionKey && globalLock.get(sessionKey)) {
3246+
api.logger.info(`memory-reflection: skipping re-entrant call for sessionKey=${sessionKey}; already running (global guard)`);
3247+
return;
3248+
}
3249+
// Serial loop guard: skip if a reflection for this sessionKey completed recently
3250+
if (sessionKey) {
3251+
const serialGuard = getSerialGuardMap();
3252+
const lastRun = serialGuard.get(sessionKey);
3253+
if (lastRun && (Date.now() - lastRun) < SERIAL_GUARD_COOLDOWN_MS) {
3254+
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)`);
3255+
return;
3256+
}
3257+
}
3258+
if (sessionKey) globalLock.set(sessionKey, true);
3259+
let reflectionRan = false;
32203260
try {
32213261
pruneReflectionSessionState();
32223262
const action = String(event?.action || "unknown");
@@ -3282,6 +3322,11 @@ const memoryLanceDBProPlugin = {
32823322
return;
32833323
}
32843324

3325+
// Mark that reflection will actually run — cooldown is only recorded
3326+
// for runs that pass all pre-condition checks, not for early exits
3327+
// (missing cfg, session file, or conversation).
3328+
reflectionRan = true;
3329+
32853330
const now = new Date(typeof event.timestamp === "number" ? event.timestamp : Date.now());
32863331
const nowTs = now.getTime();
32873332
const dateStr = now.toISOString().split("T")[0];
@@ -3476,6 +3521,10 @@ const memoryLanceDBProPlugin = {
34763521
} finally {
34773522
if (sessionKey) {
34783523
reflectionErrorStateBySession.delete(sessionKey);
3524+
getGlobalReflectionLock().delete(sessionKey);
3525+
if (reflectionRan) {
3526+
getSerialGuardMap().set(sessionKey, Date.now());
3527+
}
34793528
}
34803529
pruneReflectionSessionState();
34813530
}

0 commit comments

Comments
 (0)