mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 23:17:53 +02:00
fix(memory): recreate stale qmd collections
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user