Memory/QMD: migrate legacy unscoped collections

This commit is contained in:
Vignesh Natarajan
2026-02-21 20:31:12 -08:00
parent 961bde27fe
commit 413f81b856
3 changed files with 192 additions and 4 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.
- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81.
- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby.
- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.

View File

@@ -414,6 +414,132 @@ describe("QmdMemoryManager", () => {
expect(addSessions).toBeDefined();
});
it("migrates unscoped legacy collections before adding scoped names", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
const legacyCollections = new Map<
string,
{
path: string;
mask: string;
}
>([
["memory-root", { path: workspaceDir, mask: "MEMORY.md" }],
["memory-alt", { path: workspaceDir, mask: "memory.md" }],
["memory-dir", { path: path.join(workspaceDir, "memory"), mask: "**/*.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(
[...legacyCollections.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);
legacyCollections.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 = [...legacyCollections.entries()].some(
([existingName, info]) =>
existingName !== name && info.path === pathArg && info.mask === mask,
);
if (hasConflict) {
emitAndClose(child, "stderr", "collection already exists", 1);
return child;
}
legacyCollections.set(name, { path: pathArg, mask });
queueMicrotask(() => child.closeWith(0));
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
await manager.close();
expect(removeCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]);
expect(legacyCollections.has("memory-root-main")).toBe(true);
expect(legacyCollections.has("memory-alt-main")).toBe(true);
expect(legacyCollections.has("memory-dir-main")).toBe(true);
expect(legacyCollections.has("memory-root")).toBe(false);
expect(legacyCollections.has("memory-alt")).toBe(false);
expect(legacyCollections.has("memory-dir")).toBe(false);
});
it("does not migrate unscoped collections when listed metadata differs", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
const differentPath = path.join(tmpRoot, "other-memory");
await fs.mkdir(differentPath, { recursive: true });
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([{ name: "memory-root", path: differentPath, mask: "MEMORY.md" }]),
);
return child;
}
if (args[0] === "collection" && args[1] === "remove") {
const child = createMockChild({ autoClose: false });
removeCalls.push(args[2] ?? "");
queueMicrotask(() => child.closeWith(0));
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
await manager.close();
expect(removeCalls).not.toContain("memory-root");
expect(logDebugMock).toHaveBeenCalledWith(
expect.stringContaining("qmd legacy collection migration skipped for memory-root"),
);
});
it("times out qmd update during sync when configured", async () => {
vi.useFakeTimers();
cfg = {

View File

@@ -73,6 +73,13 @@ type ListedCollection = {
pattern?: string;
};
type ManagedCollection = {
name: string;
path: string;
pattern: string;
kind: "memory" | "custom" | "sessions";
};
type QmdManagerMode = "full" | "status";
export class QmdMemoryManager implements MemorySearchManager {
@@ -269,6 +276,8 @@ export class QmdMemoryManager implements MemorySearchManager {
// ignore; older qmd versions might not support list --json.
}
await this.migrateLegacyUnscopedCollections(existing);
for (const collection of this.qmd.collections) {
const listed = existing.get(collection.name);
if (listed && !this.shouldRebindCollection(collection, listed)) {
@@ -297,6 +306,61 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
private async migrateLegacyUnscopedCollections(
existing: Map<string, ListedCollection>,
): Promise<void> {
for (const collection of this.qmd.collections) {
if (existing.has(collection.name)) {
continue;
}
const legacyName = this.deriveLegacyCollectionName(collection.name);
if (!legacyName) {
continue;
}
const listedLegacy = existing.get(legacyName);
if (!listedLegacy) {
continue;
}
if (!this.canMigrateLegacyCollection(collection, listedLegacy)) {
log.debug(
`qmd legacy collection migration skipped for ${legacyName} (path/pattern mismatch)`,
);
continue;
}
try {
await this.removeCollection(legacyName);
existing.delete(legacyName);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!this.isCollectionMissingError(message)) {
log.warn(`qmd collection remove failed for ${legacyName}: ${message}`);
}
}
}
}
private deriveLegacyCollectionName(scopedName: string): string | null {
const agentSuffix = `-${this.sanitizeCollectionNameSegment(this.agentId)}`;
if (!scopedName.endsWith(agentSuffix)) {
return null;
}
const legacyName = scopedName.slice(0, -agentSuffix.length).trim();
return legacyName || null;
}
private canMigrateLegacyCollection(
collection: ManagedCollection,
listedLegacy: ListedCollection,
): boolean {
if (listedLegacy.path && !this.pathsMatch(listedLegacy.path, collection.path)) {
return false;
}
if (typeof listedLegacy.pattern === "string" && listedLegacy.pattern !== collection.pattern) {
return false;
}
return true;
}
private async ensureCollectionPath(collection: {
path: string;
pattern: string;
@@ -336,10 +400,7 @@ export class QmdMemoryManager implements MemorySearchManager {
});
}
private shouldRebindCollection(
collection: { kind: string; path: string; pattern: string },
listed: ListedCollection,
): boolean {
private shouldRebindCollection(collection: ManagedCollection, listed: ListedCollection): boolean {
if (!listed.path) {
// Older qmd versions may only return names from `collection list --json`.
// Rebind managed collections so stale path bindings cannot survive upgrades.