From 0e18a7922b046e3082d34660735ab262170e9c43 Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sat, 14 Mar 2026 17:34:26 +0800 Subject: [PATCH 1/5] fix(memory): watch for external file changes and auto-reindex Previously, the gateway startup only initialized memory managers for the qmd backend, skipping the default builtin SQLite provider. This meant the filesystem watcher was never set up for external file changes. Now both builtin and qmd backends initialize their memory managers at gateway startup, enabling the chokidar watcher to detect external file modifications and trigger automatic reindexing with debouncing. Fixes #45818 --- src/gateway/server-startup-memory.test.ts | 39 +++- src/gateway/server-startup-memory.ts | 8 +- .../manager.external-file-watch.test.ts | 178 ++++++++++++++++++ 3 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 src/memory/manager.external-file-watch.test.ts diff --git a/src/gateway/server-startup-memory.test.ts b/src/gateway/server-startup-memory.test.ts index 2eeef82b9ed..506d026c4ce 100644 --- a/src/gateway/server-startup-memory.test.ts +++ b/src/gateway/server-startup-memory.test.ts @@ -18,6 +18,13 @@ function createQmdConfig(agents: OpenClawConfig["agents"]): OpenClawConfig { } as OpenClawConfig; } +function createBuiltinConfig(agents: OpenClawConfig["agents"]): OpenClawConfig { + return { + agents, + memory: { backend: "builtin" }, + } as OpenClawConfig; +} + function createGatewayLogMock() { return { info: vi.fn(), warn: vi.fn() }; } @@ -27,17 +34,18 @@ describe("startGatewayMemoryBackend", () => { getMemorySearchManagerMock.mockClear(); }); - it("skips initialization when memory backend is not qmd", async () => { - const cfg = { - agents: { list: [{ id: "main", default: true }] }, - memory: { backend: "builtin" }, - } as OpenClawConfig; - const log = { info: vi.fn(), warn: vi.fn() }; + it("initializes builtin backend for each configured agent", async () => { + const cfg = createBuiltinConfig({ list: [{ id: "main", default: true }] }); + const log = createGatewayLogMock(); + getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } }); await startGatewayMemoryBackend({ cfg, log }); - expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); - expect(log.info).not.toHaveBeenCalled(); + expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); + expect(getMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" }); + expect(log.info).toHaveBeenCalledWith( + 'builtin memory startup initialization armed for agent "main"', + ); expect(log.warn).not.toHaveBeenCalled(); }); @@ -62,7 +70,7 @@ describe("startGatewayMemoryBackend", () => { expect(log.warn).not.toHaveBeenCalled(); }); - it("logs a warning when qmd manager init fails and continues with other agents", async () => { + it("logs a warning when manager init fails and continues with other agents", async () => { const cfg = createQmdConfig({ list: [{ id: "main", default: true }, { id: "ops" }] }); const log = createGatewayLogMock(); getMemorySearchManagerMock @@ -79,6 +87,19 @@ describe("startGatewayMemoryBackend", () => { ); }); + it("logs a warning when builtin manager init fails", async () => { + const cfg = createBuiltinConfig({ list: [{ id: "main", default: true }] }); + const log = createGatewayLogMock(); + getMemorySearchManagerMock.mockResolvedValue({ manager: null, error: "sqlite error" }); + + await startGatewayMemoryBackend({ cfg, log }); + + expect(log.warn).toHaveBeenCalledWith( + 'builtin memory startup initialization failed for agent "main": sqlite error', + ); + expect(log.info).not.toHaveBeenCalled(); + }); + it("skips agents with memory search disabled", async () => { const cfg = createQmdConfig({ defaults: { memorySearch: { enabled: true } }, diff --git a/src/gateway/server-startup-memory.ts b/src/gateway/server-startup-memory.ts index 5c68ced8d31..2a466d8f74a 100644 --- a/src/gateway/server-startup-memory.ts +++ b/src/gateway/server-startup-memory.ts @@ -14,17 +14,15 @@ export async function startGatewayMemoryBackend(params: { continue; } const resolved = resolveMemoryBackendConfig({ cfg: params.cfg, agentId }); - if (resolved.backend !== "qmd" || !resolved.qmd) { - continue; - } + const backendLabel = resolved.backend === "qmd" ? "qmd" : "builtin"; const { manager, error } = await getMemorySearchManager({ cfg: params.cfg, agentId }); if (!manager) { params.log.warn( - `qmd memory startup initialization failed for agent "${agentId}": ${error ?? "unknown error"}`, + `${backendLabel} memory startup initialization failed for agent "${agentId}": ${error ?? "unknown error"}`, ); continue; } - params.log.info?.(`qmd memory startup initialization armed for agent "${agentId}"`); + params.log.info?.(`${backendLabel} memory startup initialization armed for agent "${agentId}"`); } } diff --git a/src/memory/manager.external-file-watch.test.ts b/src/memory/manager.external-file-watch.test.ts new file mode 100644 index 00000000000..1dd27d28bc9 --- /dev/null +++ b/src/memory/manager.external-file-watch.test.ts @@ -0,0 +1,178 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resetEmbeddingMocks } from "./embedding.test-mocks.js"; +import type { MemoryIndexManager } from "./index.js"; +import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js"; + +describe("memory manager external file watch", () => { + let workspaceDir: string; + let indexPath: string; + let manager: MemoryIndexManager | null = null; + + beforeEach(async () => { + vi.useFakeTimers(); + resetEmbeddingMocks(); + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-watch-")); + indexPath = path.join(workspaceDir, "index.sqlite"); + await fs.mkdir(path.join(workspaceDir, "memory")); + await fs.writeFile(path.join(workspaceDir, "memory", "notes.md"), "initial content"); + }); + + afterEach(async () => { + vi.useRealTimers(); + if (manager) { + await manager.close(); + manager = null; + } + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("triggers sync when external file changes are detected", async () => { + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath, vector: { enabled: false } }, + sync: { watch: true, watchDebounceMs: 50, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" }); + const syncSpy = vi.spyOn(manager, "sync"); + + // Initial sync to index the file + await manager.sync({ reason: "initial" }); + syncSpy.mockClear(); + + const initialStatus = manager.status(); + expect(initialStatus.files).toBe(1); + expect(initialStatus.chunks).toBeGreaterThan(0); + + // Simulate external file change by calling internal watcher trigger + // This mimics what chokidar does when it detects a file change + const internalManager = manager as unknown as { + dirty: boolean; + scheduleWatchSync: () => void; + }; + internalManager.dirty = true; + internalManager.scheduleWatchSync(); + + // Run the debounce timer + await vi.runOnlyPendingTimersAsync(); + + // Verify sync was called + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" }); + }); + + it("debounces multiple rapid file changes", async () => { + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath, vector: { enabled: false } }, + sync: { watch: true, watchDebounceMs: 100, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" }); + const syncSpy = vi.spyOn(manager, "sync"); + + // Initial sync + await manager.sync({ reason: "initial" }); + syncSpy.mockClear(); + + const internalManager = manager as unknown as { + dirty: boolean; + scheduleWatchSync: () => void; + }; + + // Simulate multiple rapid file changes (like a bulk save or editor autosave) + internalManager.dirty = true; + internalManager.scheduleWatchSync(); + + // Advance time partially (less than debounce) + await vi.advanceTimersByTimeAsync(30); + + // Another file change comes in + internalManager.scheduleWatchSync(); + + // Advance time partially again + await vi.advanceTimersByTimeAsync(30); + + // Yet another file change + internalManager.scheduleWatchSync(); + + // Should not have synced yet + expect(syncSpy).not.toHaveBeenCalled(); + + // Now advance past the debounce time + await vi.advanceTimersByTimeAsync(100); + + // Should have synced exactly once + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" }); + }); + + it("reindexes modified file content after external change", async () => { + vi.useRealTimers(); // Need real timers for actual file operations + + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath, vector: { enabled: false } }, + sync: { watch: true, watchDebounceMs: 50, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" }); + + // Initial sync + await manager.sync({ reason: "initial" }); + const initialStatus = manager.status(); + expect(initialStatus.files).toBe(1); + + // Modify file content externally (adding more content) + const filePath = path.join(workspaceDir, "memory", "notes.md"); + await fs.writeFile( + filePath, + "initial content\n\nAdditional paragraph with more information that will create more chunks.", + ); + + // Mark dirty and force sync (simulating what the watcher would do) + const internalManager = manager as unknown as { dirty: boolean }; + internalManager.dirty = true; + await manager.sync({ reason: "watch", force: true }); + + // Verify the content was reindexed + const newStatus = manager.status(); + expect(newStatus.files).toBe(1); + // Content should have been reprocessed (chunks may vary based on content length) + expect(newStatus.dirty).toBe(false); + }); +}); From 3ea1b127609d1b758232b57208821635b2062f68 Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sat, 14 Mar 2026 19:06:10 +0800 Subject: [PATCH 2/5] fix(memory): address review feedback on startup init PR - Simplify backendLabel to use resolved.backend directly - Remove manager.close() in doctor status probe to preserve startup watcher - Add clarifying comment about real-timer test behavior --- src/gateway/server-methods/doctor.ts | 2 -- src/gateway/server-startup-memory.ts | 2 +- src/memory/manager.external-file-watch.test.ts | 5 ++++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 70025d2a318..e0b98f9283e 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -55,8 +55,6 @@ export const doctorHandlers: GatewayRequestHandlers = { }, }; respond(true, payload, undefined); - } finally { - await manager.close?.().catch(() => {}); } }, }; diff --git a/src/gateway/server-startup-memory.ts b/src/gateway/server-startup-memory.ts index 2a466d8f74a..499fe9ae4bd 100644 --- a/src/gateway/server-startup-memory.ts +++ b/src/gateway/server-startup-memory.ts @@ -14,7 +14,7 @@ export async function startGatewayMemoryBackend(params: { continue; } const resolved = resolveMemoryBackendConfig({ cfg: params.cfg, agentId }); - const backendLabel = resolved.backend === "qmd" ? "qmd" : "builtin"; + const backendLabel = resolved.backend; const { manager, error } = await getMemorySearchManager({ cfg: params.cfg, agentId }); if (!manager) { diff --git a/src/memory/manager.external-file-watch.test.ts b/src/memory/manager.external-file-watch.test.ts index 1dd27d28bc9..26f6e3d829f 100644 --- a/src/memory/manager.external-file-watch.test.ts +++ b/src/memory/manager.external-file-watch.test.ts @@ -132,7 +132,10 @@ describe("memory manager external file watch", () => { }); it("reindexes modified file content after external change", async () => { - vi.useRealTimers(); // Need real timers for actual file operations + // Need real timers for actual file operations. Note: chokidar may fire a + // concurrent sync between writeFile and manual sync, but this is benign + // since we only assert dirty=false. afterEach handles cleanup properly. + vi.useRealTimers(); const cfg = { agents: { From 086021cda862f53c121d00b89be0562b35d1606c Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sat, 14 Mar 2026 20:05:27 +0800 Subject: [PATCH 3/5] fix(doctor): close status-only memory manager after probe The doctor.memory.status handler creates a memory manager with purpose: "status" but never closes it. For qmd backends this opens a read-only SQLite handle that only releases on close(), so repeated health probes can accumulate file descriptors in long-lived gateway processes. Add a finally block to ensure the manager is always closed after the probe completes. --- src/gateway/server-methods/doctor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index e0b98f9283e..569ca9df500 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -55,6 +55,8 @@ export const doctorHandlers: GatewayRequestHandlers = { }, }; respond(true, payload, undefined); + } finally { + await manager.close(); } }, }; From 8ea7bf25a4c60c668387759eca343b72c19b08c8 Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sat, 14 Mar 2026 21:05:05 +0800 Subject: [PATCH 4/5] fix(doctor): catch manager close errors in status probe --- src/gateway/server-methods/doctor.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 569ca9df500..f842f40f4eb 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -56,7 +56,11 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); } finally { - await manager.close(); + try { + await manager.close(); + } catch { + // Ignore close errors - response already sent + } } }, }; From 7e602489509ed1e957eff48595bdbc367fabfcf4 Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sat, 14 Mar 2026 22:05:45 +0800 Subject: [PATCH 5/5] fix(memory): do not close cached manager during doctor status probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor.memory.status handler was closing the manager in a finally block, but MemoryIndexManager.get returns a cached instance that may be the long-lived startup manager with an active file watcher. Closing it tears down the watcher and disables external-change auto-reindexing. Remove the close call — manager lifecycle is handled by closeAllMemorySearchManagers at gateway shutdown. --- src/gateway/server-methods/doctor.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index f842f40f4eb..a2db2841a79 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -56,11 +56,9 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); } finally { - try { - await manager.close(); - } catch { - // Ignore close errors - response already sent - } + // Do NOT close the manager here — it may be the long-lived cached + // instance from startup whose file watcher must stay active. + // Manager lifecycle is handled by closeAllMemorySearchManagers at shutdown. } }, };