Merge 7e602489509ed1e957eff48595bdbc367fabfcf4 into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
Jerry-Xin 2026-03-21 14:03:51 +08:00 committed by GitHub
commit 9455ffef2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 217 additions and 15 deletions

View File

@ -56,7 +56,9 @@ export const doctorHandlers: GatewayRequestHandlers = {
}; };
respond(true, payload, undefined); respond(true, payload, undefined);
} finally { } 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.
} }
}, },
}; };

View File

@ -18,6 +18,13 @@ function createQmdConfig(agents: OpenClawConfig["agents"]): OpenClawConfig {
} as OpenClawConfig; } as OpenClawConfig;
} }
function createBuiltinConfig(agents: OpenClawConfig["agents"]): OpenClawConfig {
return {
agents,
memory: { backend: "builtin" },
} as OpenClawConfig;
}
function createGatewayLogMock() { function createGatewayLogMock() {
return { info: vi.fn(), warn: vi.fn() }; return { info: vi.fn(), warn: vi.fn() };
} }
@ -27,17 +34,18 @@ describe("startGatewayMemoryBackend", () => {
getMemorySearchManagerMock.mockClear(); getMemorySearchManagerMock.mockClear();
}); });
it("skips initialization when memory backend is not qmd", async () => { it("initializes builtin backend for each configured agent", async () => {
const cfg = { const cfg = createBuiltinConfig({ list: [{ id: "main", default: true }] });
agents: { list: [{ id: "main", default: true }] }, const log = createGatewayLogMock();
memory: { backend: "builtin" }, getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
} as OpenClawConfig;
const log = { info: vi.fn(), warn: vi.fn() };
await startGatewayMemoryBackend({ cfg, log }); await startGatewayMemoryBackend({ cfg, log });
expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1);
expect(log.info).not.toHaveBeenCalled(); expect(getMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" });
expect(log.info).toHaveBeenCalledWith(
'builtin memory startup initialization armed for agent "main"',
);
expect(log.warn).not.toHaveBeenCalled(); expect(log.warn).not.toHaveBeenCalled();
}); });
@ -62,7 +70,7 @@ describe("startGatewayMemoryBackend", () => {
expect(log.warn).not.toHaveBeenCalled(); 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 cfg = createQmdConfig({ list: [{ id: "main", default: true }, { id: "ops" }] });
const log = createGatewayLogMock(); const log = createGatewayLogMock();
getMemorySearchManagerMock 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 () => { it("skips agents with memory search disabled", async () => {
const cfg = createQmdConfig({ const cfg = createQmdConfig({
defaults: { memorySearch: { enabled: true } }, defaults: { memorySearch: { enabled: true } },

View File

@ -14,17 +14,15 @@ export async function startGatewayMemoryBackend(params: {
continue; continue;
} }
const resolved = resolveMemoryBackendConfig({ cfg: params.cfg, agentId }); const resolved = resolveMemoryBackendConfig({ cfg: params.cfg, agentId });
if (resolved.backend !== "qmd" || !resolved.qmd) { const backendLabel = resolved.backend;
continue;
}
const { manager, error } = await getMemorySearchManager({ cfg: params.cfg, agentId }); const { manager, error } = await getMemorySearchManager({ cfg: params.cfg, agentId });
if (!manager) { if (!manager) {
params.log.warn( 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; 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,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);
});
});