diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index f9f5b4a523..b0e16dc281 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -290,6 +290,107 @@ describe("QmdMemoryManager", () => { await manager?.close(); }); + it("rebinds sessions collection when existing collection path targets another agent", async () => { + const devAgentId = "dev"; + const devWorkspaceDir = path.join(tmpRoot, "workspace-dev"); + await fs.mkdir(devWorkspaceDir); + cfg = { + ...cfg, + agents: { + list: [ + { id: agentId, default: true, workspace: workspaceDir }, + { id: devAgentId, workspace: devWorkspaceDir }, + ], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: devWorkspaceDir, pattern: "**/*.md", name: "workspace" }], + sessions: { enabled: true }, + }, + }, + } as OpenClawConfig; + + const wrongSessionsPath = path.join(stateDir, "agents", agentId, "qmd", "sessions"); + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([{ name: "sessions", path: wrongSessionsPath, mask: "**/*.md" }]), + ); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: devAgentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId: devAgentId, resolved, mode: "full" }); + expect(manager).toBeTruthy(); + await manager?.close(); + + const commands = spawnMock.mock.calls.map((call) => call[1] as string[]); + const removeSessions = commands.find( + (args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions", + ); + expect(removeSessions).toBeDefined(); + + const addSessions = commands.find((args) => { + if (args[0] !== "collection" || args[1] !== "add") { + return false; + } + const nameIdx = args.indexOf("--name"); + return nameIdx >= 0 && args[nameIdx + 1] === "sessions"; + }); + expect(addSessions).toBeDefined(); + expect(addSessions?.[2]).toBe(path.join(stateDir, "agents", devAgentId, "qmd", "sessions")); + }); + + it("rebinds sessions collection when qmd only reports collection names", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + sessions: { enabled: true }, + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", JSON.stringify(["workspace", "sessions"])); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + const commands = spawnMock.mock.calls.map((call) => call[1] as string[]); + const removeSessions = commands.find( + (args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions", + ); + expect(removeSessions).toBeDefined(); + + const addSessions = commands.find((args) => { + if (args[0] !== "collection" || args[1] !== "add") { + return false; + } + const nameIdx = args.indexOf("--name"); + return nameIdx >= 0 && args[nameIdx + 1] === "sessions"; + }); + expect(addSessions).toBeDefined(); + }); + 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 33fd564682..3b422d883f 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -45,6 +45,11 @@ type SessionExporterConfig = { collectionName: string; }; +type ListedCollection = { + path?: string; + pattern?: string; +}; + type QmdManagerMode = "full" | "status"; export class QmdMemoryManager implements MemorySearchManager { @@ -203,7 +208,7 @@ export class QmdMemoryManager implements MemorySearchManager { // QMD collections are persisted inside the index database and must be created // via the CLI. Prefer listing existing collections when supported, otherwise // fall back to best-effort idempotent `qmd collection add`. - const existing = new Set(); + const existing = new Map(); try { const result = await this.runQmd(["collection", "list", "--json"], { timeoutMs: this.qmd.update.commandTimeoutMs, @@ -212,11 +217,22 @@ export class QmdMemoryManager implements MemorySearchManager { if (Array.isArray(parsed)) { for (const entry of parsed) { if (typeof entry === "string") { - existing.add(entry); + existing.set(entry, {}); } else if (entry && typeof entry === "object") { const name = (entry as { name?: unknown }).name; if (typeof name === "string") { - existing.add(name); + const listedPath = (entry as { path?: unknown }).path; + const listedPattern = (entry as { pattern?: unknown; mask?: unknown }).pattern; + const listedMask = (entry as { mask?: unknown }).mask; + existing.set(name, { + path: typeof listedPath === "string" ? listedPath : undefined, + pattern: + typeof listedPattern === "string" + ? listedPattern + : typeof listedMask === "string" + ? listedMask + : undefined, + }); } } } @@ -226,9 +242,20 @@ export class QmdMemoryManager implements MemorySearchManager { } for (const collection of this.qmd.collections) { - if (existing.has(collection.name)) { + const listed = existing.get(collection.name); + if (listed && !this.shouldRebindCollection(collection, listed)) { continue; } + if (listed) { + try { + await this.removeCollection(collection.name); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!this.isCollectionMissingError(message)) { + log.warn(`qmd collection remove failed for ${collection.name}: ${message}`); + } + } + } try { await this.addCollection(collection.path, collection.name, collection.pattern); } catch (err) { @@ -265,6 +292,35 @@ export class QmdMemoryManager implements MemorySearchManager { }); } + private shouldRebindCollection( + collection: { kind: string; path: string; pattern: string }, + listed: ListedCollection, + ): boolean { + if (!listed.path) { + // Older qmd versions may only return names from `collection list --json`. + // Force sessions collections to rebind so per-agent session export paths stay isolated. + return collection.kind === "sessions"; + } + if (!this.pathsMatch(listed.path, collection.path)) { + return true; + } + if (typeof listed.pattern === "string" && listed.pattern !== collection.pattern) { + return true; + } + return false; + } + + private pathsMatch(left: string, right: string): boolean { + const normalize = (value: string): string => { + const resolved = path.isAbsolute(value) + ? path.resolve(value) + : path.resolve(this.workspaceDir, value); + const normalized = path.normalize(resolved); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; + }; + return normalize(left) === normalize(right); + } + private shouldRepairNullByteCollectionError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); const lower = message.toLowerCase();