fix(memory): repair qmd collection name conflicts during ensure

This commit is contained in:
Vignesh Natarajan 2026-03-05 20:30:44 -08:00
parent d4021f4b92
commit 16f9f4dd22
3 changed files with 216 additions and 12 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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<string, ListedCollection>();
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<Map<string, ListedCollection>> {
const existing = new Map<string, ListedCollection>();
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, ListedCollection>,
): 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<string, ListedCollection>;
addErrorMessage: string;
}): Promise<boolean> {
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<string, ListedCollection>,
): Promise<void> {