diff --git a/CHANGELOG.md b/CHANGELOG.md index 4326784db41..c3da3312376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory/QMD collection-name conflict recovery: when `qmd collection add` fails because another collection already occupies the same `path + pattern`, detect the conflicting collection from `collection list`, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby. - Slack/app_mention race dedupe: when `app_mention` dispatch wins while same-`ts` `message` prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman. - Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy. - TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so `/model` updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 452d39d2d9b..54fb9028412 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -490,6 +490,116 @@ describe("QmdMemoryManager", () => { expect(legacyCollections.has("memory-dir")).toBe(false); }); + it("rebinds conflicting collection name when path+pattern slot is already occupied", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + const listedCollections = new Map< + string, + { + path: string; + mask: string; + } + >([["memory-root-sonnet", { path: workspaceDir, mask: "MEMORY.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( + [...listedCollections.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); + listedCollections.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 = [...listedCollections.entries()].some( + ([existingName, info]) => + existingName !== name && info.path === pathArg && info.mask === mask, + ); + if (hasConflict) { + emitAndClose(child, "stderr", "A collection already exists for this path and pattern", 1); + return child; + } + listedCollections.set(name, { path: pathArg, mask }); + queueMicrotask(() => child.closeWith(0)); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(removeCalls).toContain("memory-root-sonnet"); + expect(listedCollections.has("memory-root-main")).toBe(true); + expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("rebinding")); + }); + + it("warns instead of silently succeeding when add conflict metadata is unavailable", 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; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + // Name-only rows do not expose path/mask metadata. + emitAndClose(child, "stdout", JSON.stringify(["workspace-legacy"])); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "collection already exists", 1); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(logWarnMock).toHaveBeenCalledWith( + expect.stringContaining("qmd collection add skipped for workspace-main"), + ); + }); + it("migrates unscoped legacy collections from plain-text collection list output", async () => { cfg = { ...cfg, diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 4452a7f3459..aa58964cb9e 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -327,18 +327,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 Map(); - try { - const result = await this.runQmd(["collection", "list", "--json"], { - timeoutMs: this.qmd.update.commandTimeoutMs, - }); - const parsed = this.parseListedCollections(result.stdout); - for (const [name, details] of parsed) { - existing.set(name, details); - } - } catch { - // ignore; older qmd versions might not support list --json. - } + const existing = await this.listCollectionsBestEffort(); await this.migrateLegacyUnscopedCollections(existing); @@ -360,9 +349,21 @@ export class QmdMemoryManager implements MemorySearchManager { try { await this.ensureCollectionPath(collection); await this.addCollection(collection.path, collection.name, collection.pattern); + existing.set(collection.name, { + path: collection.path, + pattern: collection.pattern, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (this.isCollectionAlreadyExistsError(message)) { + const rebound = await this.tryRebindConflictingCollection({ + collection, + existing, + addErrorMessage: message, + }); + if (!rebound) { + log.warn(`qmd collection add skipped for ${collection.name}: ${message}`); + } continue; } log.warn(`qmd collection add failed for ${collection.name}: ${message}`); @@ -370,6 +371,98 @@ export class QmdMemoryManager implements MemorySearchManager { } } + private async listCollectionsBestEffort(): Promise> { + const existing = new Map(); + try { + const result = await this.runQmd(["collection", "list", "--json"], { + timeoutMs: this.qmd.update.commandTimeoutMs, + }); + const parsed = this.parseListedCollections(result.stdout); + for (const [name, details] of parsed) { + existing.set(name, details); + } + } catch { + // ignore; older qmd versions might not support list --json. + } + return existing; + } + + private findCollectionByPathPattern( + collection: ManagedCollection, + listed: Map, + ): string | null { + for (const [name, details] of listed) { + if (!details.path || typeof details.pattern !== "string") { + continue; + } + if (!this.pathsMatch(details.path, collection.path)) { + continue; + } + if (details.pattern !== collection.pattern) { + continue; + } + return name; + } + return null; + } + + private async tryRebindConflictingCollection(params: { + collection: ManagedCollection; + existing: Map; + addErrorMessage: string; + }): Promise { + const { collection, existing, addErrorMessage } = params; + let conflictName = this.findCollectionByPathPattern(collection, existing); + if (!conflictName) { + const refreshed = await this.listCollectionsBestEffort(); + existing.clear(); + for (const [name, details] of refreshed) { + existing.set(name, details); + } + conflictName = this.findCollectionByPathPattern(collection, existing); + } + + if (!conflictName) { + return false; + } + if (conflictName === collection.name) { + existing.set(collection.name, { + path: collection.path, + pattern: collection.pattern, + }); + return true; + } + + log.warn( + `qmd collection add conflict for ${collection.name}: path+pattern already bound by ${conflictName}; rebinding`, + ); + try { + await this.removeCollection(conflictName); + existing.delete(conflictName); + } catch (removeErr) { + const removeMessage = removeErr instanceof Error ? removeErr.message : String(removeErr); + if (!this.isCollectionMissingError(removeMessage)) { + log.warn(`qmd collection remove failed for ${conflictName}: ${removeMessage}`); + } + return false; + } + + try { + await this.addCollection(collection.path, collection.name, collection.pattern); + existing.set(collection.name, { + path: collection.path, + pattern: collection.pattern, + }); + return true; + } catch (retryErr) { + const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr); + log.warn( + `qmd collection add failed for ${collection.name} after rebinding ${conflictName}: ${retryMessage} (initial: ${addErrorMessage})`, + ); + return false; + } + } + private async migrateLegacyUnscopedCollections( existing: Map, ): Promise {