diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 70025d2a318..a2db2841a79 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -56,7 +56,9 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); } finally { - await manager.close?.().catch(() => {}); + // 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. } }, }; 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..499fe9ae4bd 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; 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..26f6e3d829f --- /dev/null +++ b/src/memory/manager.external-file-watch.test.ts @@ -0,0 +1,181 @@ +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 () => { + // 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: { + 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); + }); +});