fix(memory): recreate stale qmd collections

This commit is contained in:
Peter Steinberger
2026-04-23 22:59:54 +01:00
parent 38f157a148
commit ededff4bc3
3 changed files with 183 additions and 6 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory/QMD: recreate stale managed QMD collections when startup repair finds the collection name already exists, so root memory narrows back to `MEMORY.md` instead of staying on broad workspace markdown indexing.
- Agents/OpenAI: surface selected-model capacity failures from PI, Codex, and auto-reply harness paths with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc.
- Providers/OpenAI: route `openai/gpt-image-2` through configured Codex OAuth directly when an `openai-codex` profile is active, instead of probing `OPENAI_API_KEY` first.
- Providers/OpenAI: stop advertising the removed `gpt-5.3-codex-spark` Codex model through fallback catalogs, and suppress stale rows with a GPT-5.5 recovery hint.

View File

@@ -990,6 +990,129 @@ describe("QmdMemoryManager", () => {
);
});
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 = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
const removed: string[] = [];
const added = new Map<string, string>();
const addAttempts = new Map<string, number>();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "list") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "temporary qmd list failure", 1);
return child;
}
if (args[0] === "collection" && args[1] === "remove") {
const child = createMockChild({ autoClose: false });
const name = args[2] ?? "";
removed.push(name);
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] ?? "";
const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? "";
const attempts = addAttempts.get(name) ?? 0;
addAttempts.set(name, attempts + 1);
if (name === "memory-root-main" && attempts === 0) {
emitAndClose(child, "stderr", "Collection 'memory-root-main' already exists.", 1);
return child;
}
added.set(name, pattern);
queueMicrotask(() => child.closeWith(0));
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
await manager.close();
expect(removed).toContain("memory-root-main");
expect(added.get("memory-root-main")).toBe("MEMORY.md");
expect(logWarnMock).toHaveBeenCalledWith(
expect.stringContaining(
"qmd collection add conflict for memory-root-main: collection name already exists",
),
);
expect(logWarnMock).not.toHaveBeenCalledWith(
expect.stringContaining("qmd collection add skipped for memory-root-main"),
);
});
it("rebinds memory-root when qmd table output has a stale broad pattern", async () => {
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root");
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
const removed: string[] = [];
const added = new Map<string, string>();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "list") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
[
"Collections (2):",
"",
"memory-dir-main (qmd://memory-dir-main/)",
" Pattern: **/*.md",
"",
"memory-root-main (qmd://memory-root-main/)",
" Pattern: **/*.md",
"",
].join("\n"),
);
return child;
}
if (args[0] === "collection" && args[1] === "remove") {
const child = createMockChild({ autoClose: false });
const name = args[2] ?? "";
removed.push(name);
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] ?? "";
const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? "";
added.set(name, pattern);
queueMicrotask(() => child.closeWith(0));
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
await manager.close();
expect(removed).toContain("memory-root-main");
expect(added.get("memory-root-main")).toBe("MEMORY.md");
expect(removed).not.toContain("memory-dir-main");
});
it("falls back to --mask when qmd collection add rejects --glob", async () => {
cfg = {
...cfg,

View File

@@ -509,12 +509,22 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) {
const message = formatErrorMessage(err);
if (this.isCollectionAlreadyExistsError(message)) {
const rebound = await this.tryRebindConflictingCollection({
collection,
existing,
addErrorMessage: message,
});
if (!rebound) {
const rebound =
(await this.tryRebindSameNameCollection({
collection,
addErrorMessage: message,
})) ||
(await this.tryRebindConflictingCollection({
collection,
existing,
addErrorMessage: message,
}));
if (rebound) {
existing.set(collection.name, {
path: collection.path,
pattern: collection.pattern,
});
} else {
log.warn(`qmd collection add skipped for ${collection.name}: ${message}`);
}
continue;
@@ -524,6 +534,49 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
private async tryRebindSameNameCollection(params: {
collection: ManagedCollection;
addErrorMessage: string;
}): Promise<boolean> {
const { collection, addErrorMessage } = params;
if (!this.isSameNameCollectionAlreadyExistsError(collection.name, addErrorMessage)) {
return false;
}
log.warn(
`qmd collection add conflict for ${collection.name}: collection name already exists; recreating managed collection`,
);
try {
await this.removeCollection(collection.name);
} catch (removeErr) {
const removeMessage = formatErrorMessage(removeErr);
if (!this.isCollectionMissingError(removeMessage)) {
log.warn(`qmd collection remove failed for ${collection.name}: ${removeMessage}`);
return false;
}
}
try {
await this.ensureCollectionPath(collection);
await this.addCollection(collection.path, collection.name, collection.pattern);
return true;
} catch (retryErr) {
const retryMessage = formatErrorMessage(retryErr);
log.warn(
`qmd collection add failed for ${collection.name} after recreating same-name collection: ${retryMessage} (initial: ${addErrorMessage})`,
);
return false;
}
}
private isSameNameCollectionAlreadyExistsError(name: string, message: string): boolean {
const lowerName = normalizeLowercaseStringOrEmpty(name);
const lowerMessage = normalizeLowercaseStringOrEmpty(message);
return (
lowerMessage.includes(`collection '${lowerName}' already exists`) ||
lowerMessage.includes(`collection "${lowerName}" already exists`)
);
}
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
const existing = new Map<string, ListedCollection>();
try {