diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 4ac2e13c858..bd0aed2e7d7 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -1049,6 +1049,76 @@ describe("QmdMemoryManager", () => { ); }); + it("rebinds a path-pattern conflict when qmd add reports the stale collection name", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + let staleCollectionExists = true; + const removeCalls: string[] = []; + const addCalls: string[] = []; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + // Older qmd output may expose only names, so path/pattern matching cannot find this. + emitAndClose(child, "stdout", JSON.stringify(["workspace-legacy"])); + return child; + } + if (args[0] === "collection" && args[1] === "remove") { + const child = createMockChild({ autoClose: false }); + const name = args[2] ?? ""; + removeCalls.push(name); + if (name === "workspace-legacy") { + staleCollectionExists = false; + } + queueMicrotask(() => child.closeWith(0)); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + const name = args[args.indexOf("--name") + 1] ?? ""; + addCalls.push(name); + if (staleCollectionExists && name === "workspace-main") { + emitAndClose( + child, + "stderr", + [ + "A collection already exists for this path and pattern:", + " Name: workspace-legacy (qmd://workspace-legacy/)", + " Pattern: **/*.md", + "", + "Use 'qmd update' to re-index it, or remove it first with 'qmd collection remove workspace-legacy'", + ].join("\n"), + 1, + ); + return child; + } + queueMicrotask(() => child.closeWith(0)); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(removeCalls).toEqual(["workspace-legacy"]); + expect(addCalls).toEqual(["workspace-main", "workspace-main"]); + expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("rebinding")); + expect(logWarnMock).not.toHaveBeenCalledWith( + expect.stringContaining("qmd collection add skipped for workspace-main"), + ); + }); + it("recreates a managed collection when list fails but add reports the same name exists", async () => { await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); cfg = { diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 0af76e4428f..accfac525df 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -643,6 +643,18 @@ export class QmdMemoryManager implements MemorySearchManager { return null; } + private parseConflictingCollectionNameFromAddError(message: string): string | null { + if ( + !normalizeLowercaseStringOrEmpty(message).includes( + "a collection already exists for this path and pattern", + ) + ) { + return null; + } + const match = /^\s*Name:\s*([a-z0-9._-]+)\s*\(qmd:\/\/[^)\s]+\/?\)\s*$/im.exec(message); + return match?.[1] ?? null; + } + private async tryRebindConflictingCollection(params: { collection: ManagedCollection; existing: Map; @@ -659,6 +671,8 @@ export class QmdMemoryManager implements MemorySearchManager { conflictName = this.findCollectionByPathPattern(collection, existing); } + conflictName ??= this.parseConflictingCollectionNameFromAddError(addErrorMessage); + if (!conflictName) { return false; }