From 133323875fd9dbf6c8bb67476fb9bca58fead004 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 22:57:59 -0700 Subject: [PATCH] fix(cli): close standalone memory managers after commands (#118500) --- src/plugins/memory-runtime.test.ts | 68 ++++++++++++++++++++++++++++++ src/plugins/memory-runtime.ts | 11 ++++- src/plugins/memory-state.ts | 9 +++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/plugins/memory-runtime.test.ts b/src/plugins/memory-runtime.test.ts index 44dd7d305a34..6d3926274a8d 100644 --- a/src/plugins/memory-runtime.test.ts +++ b/src/plugins/memory-runtime.test.ts @@ -31,6 +31,7 @@ import { resolveActiveMemoryBackendConfig, } from "./memory-runtime.js"; import { resetStandaloneMemoryRegistrySlot } from "./memory-runtime.test-support.js"; +import { hasMemoryRuntime } from "./memory-state.js"; function createRuntime() { return { @@ -95,6 +96,73 @@ describe("memory runtime handles", () => { }); }); + it("tracks standalone managers without activating config-only lookups and rearms reused handles", async () => { + const { registry, runtime } = createRegistry(); + mocks.loadPluginRegistryHandle.mockReturnValue(registry); + + expect(hasMemoryRuntime()).toBe(false); + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", + }); + expect(hasMemoryRuntime()).toBe(false); + + await getActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }); + expect(hasMemoryRuntime()).toBe(true); + + await closeActiveMemorySearchManagers(); + expect(hasMemoryRuntime()).toBe(false); + + await getActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }); + expect(hasMemoryRuntime()).toBe(true); + expect(mocks.loadPluginRegistryHandle).toHaveBeenCalledTimes(1); + + await closeActiveMemorySearchManagers(); + expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(2); + expect(hasMemoryRuntime()).toBe(false); + }); + + it("retains standalone ownership across workspace replacement and per-agent cleanup", async () => { + const main = createRegistry(); + const research = createRegistry(); + mocks.loadPluginRegistryHandle + .mockReturnValueOnce(main.registry) + .mockReturnValueOnce(research.registry); + + await getActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }); + await getActiveMemorySearchManager({ cfg: memoryConfig, agentId: "research" }); + expect(hasMemoryRuntime()).toBe(true); + + await closeActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }); + expect(hasMemoryRuntime()).toBe(true); + + await closeActiveMemorySearchManagers(); + expect(main.runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1); + expect(research.runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1); + expect(hasMemoryRuntime()).toBe(false); + }); + + it("retains standalone cleanup ownership when manager acquisition or teardown fails", async () => { + const { registry, runtime } = createRegistry(); + mocks.loadPluginRegistryHandle.mockReturnValue(registry); + runtime.getMemorySearchManager.mockRejectedValueOnce( + new Error("manager initialization failed"), + ); + + await expect( + getActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }), + ).rejects.toThrow("manager initialization failed"); + expect(hasMemoryRuntime()).toBe(true); + + runtime.closeAllMemorySearchManagers.mockRejectedValueOnce( + new Error("manager teardown failed"), + ); + await expect(closeActiveMemorySearchManagers()).rejects.toThrow("manager teardown failed"); + expect(hasMemoryRuntime()).toBe(true); + + await closeActiveMemorySearchManagers(); + expect(hasMemoryRuntime()).toBe(false); + }); + it("keys the single slot by the requesting agent workspace", () => { const main = createRegistry(); const research = createRegistry(); diff --git a/src/plugins/memory-runtime.ts b/src/plugins/memory-runtime.ts index 23dbf39d630d..252c7f1d984c 100644 --- a/src/plugins/memory-runtime.ts +++ b/src/plugins/memory-runtime.ts @@ -4,7 +4,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; import { normalizePluginsConfig } from "./config-state.js"; import { loadPluginRegistryHandle, resolvePluginRegistryLoadCacheKey } from "./loader.js"; -import { getMemoryRuntime, resolveMemoryCapabilityRegistration } from "./memory-state.js"; +import { + getMemoryRuntime, + resolveMemoryCapabilityRegistration, + setStandaloneMemoryManagerActive, +} from "./memory-state.js"; import type { PluginRegistry } from "./registry-types.js"; import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; @@ -121,6 +125,9 @@ export async function getActiveMemorySearchManager(params: { if (!owner) { return { manager: null, error: "memory plugin unavailable" }; } + if (owner.registry) { + setStandaloneMemoryManagerActive(true); + } return await withMemoryRuntimeOwner( owner, async (runtime) => await runtime.getMemorySearchManager(params), @@ -146,6 +153,7 @@ export async function closeActiveMemorySearchManagers(cfg?: OpenClawConfig): Pro ), ); standaloneMemoryRegistrySlot?.retiredRuntimes.clear(); + setStandaloneMemoryManagerActive(false); } /** Closes the plugin-backed memory search manager for one agent. */ @@ -164,6 +172,7 @@ export async function closeActiveMemorySearchManager(params: { function resetStandaloneMemoryRegistrySlot(): void { standaloneMemoryRegistrySlot = undefined; + setStandaloneMemoryManagerActive(false); } if (process.env.VITEST || process.env.NODE_ENV === "test") { diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index 4c63ef5b41ab..53f069420e67 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -266,8 +266,15 @@ export function getMemoryRuntime(): MemoryPluginRuntime | undefined { return getMemoryCapability()?.capability.runtime; } +let standaloneMemoryManagerActive = false; + +// Standalone managers are intentionally absent from the active plugin registry. +export function setStandaloneMemoryManagerActive(active: boolean): void { + standaloneMemoryManagerActive = active; +} + export function hasMemoryRuntime(): boolean { - return getMemoryRuntime() !== undefined; + return standaloneMemoryManagerActive || getMemoryRuntime() !== undefined; } function cloneMemoryPublicArtifact(