fix(memory): repair qmd collection name conflicts during ensure
This commit is contained in:
parent
d4021f4b92
commit
16f9f4dd22
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user