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
This commit is contained in:
Jerry-Xin 2026-03-14 17:34:26 +08:00
parent c08f2aa21a
commit 0e18a7922b
3 changed files with 211 additions and 14 deletions

View File

@ -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 } },

View File

@ -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}"`);
}
}

View File

@ -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);
});
});