fix(memory): harden qmd collection recovery

This commit is contained in:
Vignesh Natarajan
2026-02-22 14:39:28 -08:00
parent 5858de6078
commit 82d34b4b06
3 changed files with 316 additions and 71 deletions

View File

@@ -496,6 +496,65 @@ describe("QmdMemoryManager", () => {
expect(legacyCollections.has("memory-dir")).toBe(false);
});
it("migrates unscoped legacy collections from plain-text collection list output", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
const removeCalls: string[] = [];
const addCalls: string[] = [];
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "list") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
[
"Collections (3):",
"",
"memory-root (qmd://memory-root/)",
" Pattern: MEMORY.md",
"",
"memory-alt (qmd://memory-alt/)",
" Pattern: memory.md",
"",
"memory-dir (qmd://memory-dir/)",
" Pattern: **/*.md",
"",
].join("\n"),
);
return child;
}
if (args[0] === "collection" && args[1] === "remove") {
const child = createMockChild({ autoClose: false });
removeCalls.push(args[2] ?? "");
queueMicrotask(() => child.closeWith(0));
return child;
}
if (args[0] === "collection" && args[1] === "add") {
const child = createMockChild({ autoClose: false });
addCalls.push(args[args.indexOf("--name") + 1] ?? "");
queueMicrotask(() => child.closeWith(0));
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
await manager.close();
expect(removeCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]);
expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]);
});
it("does not migrate unscoped collections when listed metadata differs", async () => {
cfg = {
...cfg,
@@ -729,6 +788,96 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("repairs missing managed collections and retries search once", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
searchMode: "search",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
const expectedDocId = "abc123";
let missingCollectionSeen = false;
let addCallsAfterMissing = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "list") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
if (args[0] === "collection" && args[1] === "add") {
if (missingCollectionSeen) {
addCallsAfterMissing += 1;
}
return createMockChild();
}
if (args[0] === "search") {
const collectionFlagIndex = args.indexOf("-c");
const collection = collectionFlagIndex >= 0 ? args[collectionFlagIndex + 1] : "";
if (collection === "memory-root-main" && !missingCollectionSeen) {
missingCollectionSeen = true;
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "Collection not found: memory-root-main", 1);
return child;
}
if (collection === "memory-root-main") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([{ docid: expectedDocId, score: 1, snippet: "@@ -1,1\nremember this" }]),
);
return child;
}
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
const inner = manager as unknown as {
db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void };
};
inner.db = {
prepare: (_query: string) => ({
all: (arg: unknown) => {
if (typeof arg === "string" && arg.startsWith(expectedDocId)) {
return [{ collection: "memory-root-main", path: "MEMORY.md" }];
}
return [];
},
}),
close: () => {},
};
await expect(
manager.search("remember", { sessionKey: "agent:main:slack:dm:u123" }),
).resolves.toEqual([
{
path: "MEMORY.md",
startLine: 1,
endLine: 1,
score: 1,
snippet: "@@ -1,1\nremember this",
source: "memory",
},
]);
expect(addCallsAfterMissing).toBeGreaterThan(0);
expect(logWarnMock).toHaveBeenCalledWith(
expect.stringContaining("repairing collections and retrying once"),
);
await manager.close();
});
it("uses qmd.cmd on Windows when qmd command is bare", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {