Merge 7e602489509ed1e957eff48595bdbc367fabfcf4 into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
9455ffef2e
@ -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.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 } },
|
||||||
|
|||||||
@ -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}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
181
src/memory/manager.external-file-watch.test.ts
Normal file
181
src/memory/manager.external-file-watch.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user