From 413f81b8562d9fb0b2f7f71eb200afcf6a0e04d2 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 21 Feb 2026 20:31:12 -0800 Subject: [PATCH] Memory/QMD: migrate legacy unscoped collections --- CHANGELOG.md | 1 + src/memory/qmd-manager.test.ts | 126 +++++++++++++++++++++++++++++++++ src/memory/qmd-manager.ts | 69 ++++++++++++++++-- 3 files changed, 192 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ab01cd6..72daec0c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. - Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai. - Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81. +- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby. - TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends. - TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. - Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 49dfca02f..8503616ea 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -414,6 +414,132 @@ describe("QmdMemoryManager", () => { expect(addSessions).toBeDefined(); }); + it("migrates unscoped legacy collections before adding scoped names", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + const legacyCollections = new Map< + string, + { + path: string; + mask: string; + } + >([ + ["memory-root", { path: workspaceDir, mask: "MEMORY.md" }], + ["memory-alt", { path: workspaceDir, mask: "memory.md" }], + ["memory-dir", { path: path.join(workspaceDir, "memory"), mask: "**/*.md" }], + ]); + const removeCalls: string[] = []; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify( + [...legacyCollections.entries()].map(([name, info]) => ({ + name, + path: info.path, + mask: info.mask, + })), + ), + ); + return child; + } + if (args[0] === "collection" && args[1] === "remove") { + const child = createMockChild({ autoClose: false }); + const name = args[2] ?? ""; + removeCalls.push(name); + legacyCollections.delete(name); + queueMicrotask(() => child.closeWith(0)); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + const pathArg = args[2] ?? ""; + const name = args[args.indexOf("--name") + 1] ?? ""; + const mask = args[args.indexOf("--mask") + 1] ?? ""; + const hasConflict = [...legacyCollections.entries()].some( + ([existingName, info]) => + existingName !== name && info.path === pathArg && info.mask === mask, + ); + if (hasConflict) { + emitAndClose(child, "stderr", "collection already exists", 1); + return child; + } + legacyCollections.set(name, { path: pathArg, mask }); + 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(legacyCollections.has("memory-root-main")).toBe(true); + expect(legacyCollections.has("memory-alt-main")).toBe(true); + expect(legacyCollections.has("memory-dir-main")).toBe(true); + expect(legacyCollections.has("memory-root")).toBe(false); + expect(legacyCollections.has("memory-alt")).toBe(false); + expect(legacyCollections.has("memory-dir")).toBe(false); + }); + + it("does not migrate unscoped collections when listed metadata differs", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + const differentPath = path.join(tmpRoot, "other-memory"); + await fs.mkdir(differentPath, { recursive: true }); + const removeCalls: string[] = []; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([{ name: "memory-root", path: differentPath, mask: "MEMORY.md" }]), + ); + return child; + } + if (args[0] === "collection" && args[1] === "remove") { + const child = createMockChild({ autoClose: false }); + removeCalls.push(args[2] ?? ""); + queueMicrotask(() => child.closeWith(0)); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(removeCalls).not.toContain("memory-root"); + expect(logDebugMock).toHaveBeenCalledWith( + expect.stringContaining("qmd legacy collection migration skipped for memory-root"), + ); + }); + it("times out qmd update during sync when configured", async () => { vi.useFakeTimers(); cfg = { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 33bda6349..03f49de61 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -73,6 +73,13 @@ type ListedCollection = { pattern?: string; }; +type ManagedCollection = { + name: string; + path: string; + pattern: string; + kind: "memory" | "custom" | "sessions"; +}; + type QmdManagerMode = "full" | "status"; export class QmdMemoryManager implements MemorySearchManager { @@ -269,6 +276,8 @@ export class QmdMemoryManager implements MemorySearchManager { // ignore; older qmd versions might not support list --json. } + await this.migrateLegacyUnscopedCollections(existing); + for (const collection of this.qmd.collections) { const listed = existing.get(collection.name); if (listed && !this.shouldRebindCollection(collection, listed)) { @@ -297,6 +306,61 @@ export class QmdMemoryManager implements MemorySearchManager { } } + private async migrateLegacyUnscopedCollections( + existing: Map, + ): Promise { + for (const collection of this.qmd.collections) { + if (existing.has(collection.name)) { + continue; + } + const legacyName = this.deriveLegacyCollectionName(collection.name); + if (!legacyName) { + continue; + } + const listedLegacy = existing.get(legacyName); + if (!listedLegacy) { + continue; + } + if (!this.canMigrateLegacyCollection(collection, listedLegacy)) { + log.debug( + `qmd legacy collection migration skipped for ${legacyName} (path/pattern mismatch)`, + ); + continue; + } + try { + await this.removeCollection(legacyName); + existing.delete(legacyName); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!this.isCollectionMissingError(message)) { + log.warn(`qmd collection remove failed for ${legacyName}: ${message}`); + } + } + } + } + + private deriveLegacyCollectionName(scopedName: string): string | null { + const agentSuffix = `-${this.sanitizeCollectionNameSegment(this.agentId)}`; + if (!scopedName.endsWith(agentSuffix)) { + return null; + } + const legacyName = scopedName.slice(0, -agentSuffix.length).trim(); + return legacyName || null; + } + + private canMigrateLegacyCollection( + collection: ManagedCollection, + listedLegacy: ListedCollection, + ): boolean { + if (listedLegacy.path && !this.pathsMatch(listedLegacy.path, collection.path)) { + return false; + } + if (typeof listedLegacy.pattern === "string" && listedLegacy.pattern !== collection.pattern) { + return false; + } + return true; + } + private async ensureCollectionPath(collection: { path: string; pattern: string; @@ -336,10 +400,7 @@ export class QmdMemoryManager implements MemorySearchManager { }); } - private shouldRebindCollection( - collection: { kind: string; path: string; pattern: string }, - listed: ListedCollection, - ): boolean { + private shouldRebindCollection(collection: ManagedCollection, listed: ListedCollection): boolean { if (!listed.path) { // Older qmd versions may only return names from `collection list --json`. // Rebind managed collections so stale path bindings cannot survive upgrades.