From 9dcd53c0b676a01fa0dce23b9b4be1d5a0e022f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 11:50:37 +0100 Subject: [PATCH] fix(memory): avoid watchers for memory CLI commands --- CHANGELOG.md | 1 + extensions/memory-core/src/cli.runtime.ts | 4 +- extensions/memory-core/src/cli.test.ts | 15 +++++++ extensions/memory-core/src/memory/index.ts | 1 + extensions/memory-core/src/memory/manager.ts | 18 ++++---- .../src/memory/manager.watcher-config.test.ts | 11 +++++ .../src/memory/qmd-manager.test.ts | 29 ++++++++++++- .../memory-core/src/memory/qmd-manager.ts | 6 ++- .../src/memory/search-manager.test.ts | 43 +++++++++++++++++++ .../memory-core/src/memory/search-manager.ts | 20 +++++---- .../src/memory/test-manager-helpers.ts | 2 +- extensions/memory-core/src/tools.shared.ts | 2 +- 12 files changed, 131 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d606b2ddf56..a7d11ae7949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so `memory index`, `memory status --index`, and `memory search` no longer start long-lived file watchers that can hit macOS `EMFILE` limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue. - Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010. - WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset ` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg. - CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode. diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index f09522daf30..d053e601391 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -680,7 +680,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { }> = []; for (const agentId of agentIds) { - const managerPurpose = opts.index ? "default" : "status"; + const managerPurpose = opts.index ? "cli" : "status"; await withMemoryManagerForAgent({ cfg, agentId, @@ -1025,6 +1025,7 @@ export async function runMemoryIndex(opts: MemoryCommandOptions) { await withMemoryManagerForAgent({ cfg, agentId, + purpose: "cli", run: async (manager) => { try { const syncFn = manager.sync ? manager.sync.bind(manager) : undefined; @@ -1177,6 +1178,7 @@ export async function runMemorySearch( await withMemoryManagerForAgent({ cfg, agentId, + purpose: "cli", run: async (manager) => { const sessionKey = buildCliMemorySearchSessionKey(agentId); let results: Awaited>; diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index d463823554c..3637e5f0284 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -558,6 +558,11 @@ describe("memory cli", () => { expectCliSync(sync); expect(probeEmbeddingAvailability).toHaveBeenCalled(); + expect(getMemorySearchManager).toHaveBeenCalledWith({ + cfg: {}, + agentId: "main", + purpose: "cli", + }); expect(close).toHaveBeenCalled(); }); @@ -570,6 +575,11 @@ describe("memory cli", () => { await runMemoryCli(["index"]); expectCliSync(sync); + expect(getMemorySearchManager).toHaveBeenCalledWith({ + cfg: {}, + agentId: "main", + purpose: "cli", + }); expect(close).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith("Memory index updated (main)."); }); @@ -785,6 +795,11 @@ describe("memory cli", () => { minScore: undefined, sessionKey: "agent:main:cli:direct:memory-search", }); + expect(getMemorySearchManager).toHaveBeenCalledWith({ + cfg: {}, + agentId: "main", + purpose: "cli", + }); expect(log).toHaveBeenCalledWith("No matches."); expect(close).toHaveBeenCalled(); }); diff --git a/extensions/memory-core/src/memory/index.ts b/extensions/memory-core/src/memory/index.ts index bb201554f7b..5e3b726c8f3 100644 --- a/extensions/memory-core/src/memory/index.ts +++ b/extensions/memory-core/src/memory/index.ts @@ -7,5 +7,6 @@ export type { export { closeAllMemorySearchManagers, getMemorySearchManager, + type MemorySearchManagerPurpose, type MemorySearchManagerResult, } from "./search-manager.js"; diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 781cdd69e71..cdc0c3af7b9 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -62,6 +62,7 @@ const FTS_TABLE = "chunks_fts"; const EMBEDDING_CACHE_TABLE = "embedding_cache"; const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCache"); const log = createSubsystemLogger("memory"); +type MemoryIndexManagerPurpose = "default" | "status" | "cli"; const { cache: INDEX_CACHE, pending: INDEX_CACHE_PENDING } = resolveSingletonManagedCache(MEMORY_INDEX_MANAGER_CACHE_KEY); @@ -155,7 +156,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem static async get(params: { cfg: OpenClawConfig; agentId: string; - purpose?: "default" | "status"; + purpose?: MemoryIndexManagerPurpose; }): Promise { const { cfg, agentId } = params; const settings = resolveMemorySearchConfig(cfg, agentId); @@ -163,14 +164,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem return null; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const purpose = params.purpose === "status" ? "status" : "default"; + const purpose = + params.purpose === "status" || params.purpose === "cli" ? params.purpose : "default"; const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}:${purpose}`; - const statusOnly = params.purpose === "status"; + const transient = purpose === "status" || purpose === "cli"; return await getOrCreateManagedCacheEntry({ cache: INDEX_CACHE, pending: INDEX_CACHE_PENDING, key, - bypassCache: statusOnly, + bypassCache: transient, create: async () => new MemoryIndexManager({ cacheKey: key, @@ -190,7 +192,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem workspaceDir: string; settings: ResolvedMemorySearchConfig; providerResult?: EmbeddingProviderResult; - purpose?: "default" | "status"; + purpose?: MemoryIndexManagerPurpose; }) { super(); this.cacheKey = params.cacheKey; @@ -221,15 +223,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem if (meta?.vectorDims) { this.vector.dims = meta.vectorDims; } - const statusOnly = params.purpose === "status"; - if (!statusOnly) { + const transient = params.purpose === "status" || params.purpose === "cli"; + if (!transient) { this.ensureWatcher(); this.ensureSessionListener(); this.ensureIntervalSync(); } this.dirty = resolveInitialMemoryDirty({ hasMemorySource: this.sources.has("memory"), - statusOnly, + statusOnly: params.purpose === "status", hasIndexedMeta: Boolean(meta), }); this.batch = this.resolveBatchConfig(); diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 64da449505a..916756e11e7 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -190,6 +190,17 @@ describe("memory watcher config", () => { ).toBe(false); }); + it("does not start watchers for one-shot CLI managers", async () => { + await setupWatcherWorkspace({ name: "notes.md", contents: "hello" }); + const cfg = createWatcherConfig(); + + const result = await getMemorySearchManager({ cfg, agentId: "main", purpose: "cli" }); + expect(result.manager).not.toBeNull(); + manager = result.manager as unknown as MemoryIndexManager; + + expect(watchMock).not.toHaveBeenCalled(); + }); + it("watches multimodal extra directories with filtered extensions", async () => { await setupWatcherWorkspace({ name: "PHOTO.PNG", contents: "png" }); const cfg = createWatcherConfig({ diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 7a35e8a5242..0d3335f10c6 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -171,7 +171,10 @@ describe("QmdMemoryManager", () => { return manager; } - async function createManager(params?: { mode?: "full" | "status"; cfg?: OpenClawConfig }) { + async function createManager(params?: { + mode?: "full" | "status" | "cli"; + cfg?: OpenClawConfig; + }) { const cfgToUse = params?.cfg ?? cfg; const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId }); const manager = trackManager( @@ -486,6 +489,30 @@ describe("QmdMemoryManager", () => { await manager?.close(); }); + it("initializes one-shot CLI mode without watchers or background updates", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "5m", debounceMs: 60_000, onBoot: true }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + const { manager } = await createManager({ mode: "cli" }); + + expect(watchMock).not.toHaveBeenCalled(); + const updateCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); + expect(updateCalls).toEqual([]); + + await manager?.close(); + }); + it("can be configured to block startup on boot update", async () => { cfg = { ...cfg, diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 5ca07dad05b..d525373225d 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -210,7 +210,7 @@ type ManagedCollection = { kind: "memory" | "custom" | "sessions"; }; -type QmdManagerMode = "full" | "status"; +type QmdManagerMode = "full" | "status" | "cli"; type QmdManagerRuntimeConfig = { workspaceDir: string; syncSettings: ReturnType; @@ -414,6 +414,10 @@ export class QmdMemoryManager implements MemorySearchManager { await this.symlinkSharedModels(); await this.ensureCollections(); + if (mode === "cli") { + return; + } + this.ensureWatcher(); if (this.qmd.update.onBoot) { diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index d71c87de932..c3d315c7f58 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -643,6 +643,49 @@ describe("getMemorySearchManager caching", () => { expect(mockPrimary.close).toHaveBeenCalledTimes(2); }); + it("does not reuse cached full qmd managers for one-shot CLI requests", async () => { + const agentId = "cli-agent"; + const cfg = createQmdCfg(agentId); + const fullPrimary = createManagerMock({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }); + const cliPrimary = createManagerMock({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }); + createQmdManagerMock + .mockImplementationOnce(async () => fullPrimary as unknown as QmdManagerInstance) + .mockImplementationOnce(async () => cliPrimary as unknown as QmdManagerInstance); + + const full = await getMemorySearchManager({ cfg, agentId }); + const cli = await getMemorySearchManager({ cfg, agentId, purpose: "cli" }); + const fullManager = requireManager(full); + const cliManager = requireManager(cli); + + expect(cliManager).toBe(cliPrimary); + expect(cliManager).not.toBe(fullManager); + expect(createQmdManagerMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ agentId, mode: "full" }), + ); + expect(createQmdManagerMock.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ agentId, mode: "cli" }), + ); + + await cli.manager?.close?.(); + expect(cliPrimary.close).toHaveBeenCalledTimes(1); + expect(fullPrimary.close).not.toHaveBeenCalled(); + + const fullAgain = await getMemorySearchManager({ cfg, agentId }); + expect(fullAgain.manager).toBe(fullManager); + }); + it("does not cache builtin managers for status-only requests", async () => { const agentId = "builtin-status-agent"; const cfg = createBuiltinCfg(agentId); diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index 0e6d11ce474..ce381a51392 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -92,10 +92,12 @@ export type MemorySearchManagerResult = { error?: string; }; +export type MemorySearchManagerPurpose = "default" | "status" | "cli"; + export async function getMemorySearchManager(params: { cfg: OpenClawConfig; agentId: string; - purpose?: "default" | "status"; + purpose?: MemorySearchManagerPurpose; }): Promise { const resolved = resolveMemoryBackendConfig(params); if (resolved.backend === "qmd" && resolved.qmd) { @@ -103,12 +105,12 @@ export async function getMemorySearchManager(params: { const normalizedAgentId = normalizeAgentId(params.agentId); const runtimeConfig = resolveQmdManagerRuntimeConfig(params.cfg, normalizedAgentId); const { workspaceDir } = runtimeConfig; - const statusOnly = params.purpose === "status"; + const transient = params.purpose === "status" || params.purpose === "cli"; const scopeKey = buildQmdManagerScopeKey(normalizedAgentId); const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig); const createPrimaryQmdManager = async ( - mode: "full" | "status", + mode: "full" | "status" | "cli", ): Promise> => { try { await fs.mkdir(workspaceDir, { recursive: true }); @@ -183,17 +185,19 @@ export async function getMemorySearchManager(params: { const cached = QMD_MANAGER_CACHE.get(scopeKey); const cachedMatchesIdentity = cached?.identityKey === identityKey; if (cachedMatchesIdentity) { - if (statusOnly) { + if (params.purpose === "status") { // Status callers often close the manager they receive. Wrap the live // full manager with a no-op close so health/status probes do not tear // down the active QMD manager for the process. return { manager: new BorrowedMemoryManager(cached.manager) }; } - return { manager: cached.manager }; + if (params.purpose !== "cli") { + return { manager: cached.manager }; + } } - if (statusOnly) { - const manager = await createPrimaryQmdManager("status"); + if (transient) { + const manager = await createPrimaryQmdManager(params.purpose === "cli" ? "cli" : "status"); return manager ? { manager } : await getBuiltinMemorySearchManager(params); } @@ -236,7 +240,7 @@ export async function getMemorySearchManager(params: { async function getBuiltinMemorySearchManager(params: { cfg: OpenClawConfig; agentId: string; - purpose?: "default" | "status"; + purpose?: MemorySearchManagerPurpose; }): Promise { try { const { MemoryIndexManager } = await loadManagerRuntime(); diff --git a/extensions/memory-core/src/memory/test-manager-helpers.ts b/extensions/memory-core/src/memory/test-manager-helpers.ts index 5690dd70d62..62f718c3a9f 100644 --- a/extensions/memory-core/src/memory/test-manager-helpers.ts +++ b/extensions/memory-core/src/memory/test-manager-helpers.ts @@ -20,7 +20,7 @@ async function loadGetMemorySearchManager(): Promise { await ensureEmbeddingMocksLoaded(); const getMemorySearchManager = await loadGetMemorySearchManager(); diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts index 04c2120e816..b1a6f99924e 100644 --- a/extensions/memory-core/src/tools.shared.ts +++ b/extensions/memory-core/src/tools.shared.ts @@ -81,7 +81,7 @@ export async function getMemoryManagerContext(params: { export async function getMemoryManagerContextWithPurpose(params: { cfg: OpenClawConfig; agentId: string; - purpose?: "default" | "status"; + purpose?: "default" | "status" | "cli"; }): Promise< | { manager: NonNullable;