Skip to content

Commit 22bda60

Browse files
sasan1200vincentkoc
authored andcommitted
fix(memory): rebind qmd collections when a collection root changes
ensureCollections() never rebound a managed collection after its root path changed (e.g. an agent workspace repoint): listCollectionsBestEffort() read `qmd collection list`, whose output carries no filesystem path, so listed.path was always undefined and shouldRebindCollection took its defensive branch and skipped the rebind. The collection stayed pinned to the stale root and recall kept resolving the old location. Enrich the listed collections with their path via `qmd collection show` so path-change detection works and the rebind fires. Closes #91251 (cherry picked from commit 4a22501)
1 parent 57633c4 commit 22bda60

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5848,6 +5848,170 @@ describe("QmdMemoryManager", () => {
58485848
}
58495849
});
58505850
});
5851+
5852+
it("rebinds a managed collection when its root path changed (show reveals old path)", async () => {
5853+
// Regression: listCollectionsBestEffort gets only the name from `collection list`
5854+
// (no path). The fix enriches path via `collection show`; without it shouldRebindCollection
5855+
// hits the `!listed.path` branch and skips the rebind, leaving the old path pinned.
5856+
const oldWorkspaceDir = path.join(tmpRoot, "old-workspace");
5857+
const newWorkspaceDir = workspaceDir; // the manager is configured for this new path
5858+
5859+
cfg = {
5860+
...cfg,
5861+
memory: {
5862+
backend: "qmd",
5863+
qmd: {
5864+
includeDefaultMemory: false,
5865+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
5866+
paths: [{ path: newWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
5867+
},
5868+
},
5869+
} as OpenClawConfig;
5870+
5871+
const collectionName = `workspace-${agentId}`;
5872+
5873+
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
5874+
if (args[0] === "collection" && args[1] === "list") {
5875+
// Real qmd: names only, no path/pattern in list output.
5876+
const child = createMockChild({ autoClose: false });
5877+
emitAndClose(child, "stdout", JSON.stringify([collectionName]));
5878+
return child;
5879+
}
5880+
if (args[0] === "collection" && args[1] === "show" && args[2] === collectionName) {
5881+
// Real qmd `collection show` output — exposes the stale (old) path.
5882+
const child = createMockChild({ autoClose: false });
5883+
emitAndClose(
5884+
child,
5885+
"stdout",
5886+
[
5887+
`Collection: ${collectionName}`,
5888+
` Path: ${oldWorkspaceDir}`,
5889+
` Pattern: **/*.md`,
5890+
` Include: yes (default)`,
5891+
].join("\n"),
5892+
);
5893+
return child;
5894+
}
5895+
return createMockChild();
5896+
});
5897+
5898+
const { manager } = await createManager({ mode: "full" });
5899+
await manager.close();
5900+
5901+
const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]);
5902+
5903+
const removeCall = commands.find(
5904+
(args) => args[0] === "collection" && args[1] === "remove" && args[2] === collectionName,
5905+
);
5906+
expect(removeCall).toBeDefined(); // rebind must remove the stale collection
5907+
5908+
const addCall = commands.find((args) => {
5909+
if (args[0] !== "collection" || args[1] !== "add") {
5910+
return false;
5911+
}
5912+
const nameIdx = args.indexOf("--name");
5913+
return nameIdx >= 0 && args[nameIdx + 1] === collectionName;
5914+
});
5915+
expect(addCall).toBeDefined();
5916+
// The new add must target the NEW workspace path, not the old one.
5917+
expect(addCall?.[2]).toBe(newWorkspaceDir);
5918+
});
5919+
5920+
it("rebinds a stale in-container collection root to the host workspace (sandbox-mode transition)", async () => {
5921+
// Sandbox coverage: an agent that previously ran with its workspace bind-mounted under
5922+
// /home/node/.openclaw/... stored that in-container path as the collection root. Resolved
5923+
// with host paths, `collection show` reveals the stale container path; the rebind is
5924+
// path-namespace-agnostic and re-binds to the current host root.
5925+
const containerRoot = "/home/node/.openclaw/teams/x/workspace";
5926+
const newWorkspaceDir = workspaceDir; // host path the manager is configured for
5927+
5928+
cfg = {
5929+
...cfg,
5930+
memory: {
5931+
backend: "qmd",
5932+
qmd: {
5933+
includeDefaultMemory: false,
5934+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
5935+
paths: [{ path: newWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
5936+
},
5937+
},
5938+
} as OpenClawConfig;
5939+
5940+
const collectionName = `workspace-${agentId}`;
5941+
5942+
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
5943+
if (args[0] === "collection" && args[1] === "list") {
5944+
const child = createMockChild({ autoClose: false });
5945+
emitAndClose(child, "stdout", JSON.stringify([collectionName]));
5946+
return child;
5947+
}
5948+
if (args[0] === "collection" && args[1] === "show" && args[2] === collectionName) {
5949+
const child = createMockChild({ autoClose: false });
5950+
emitAndClose(
5951+
child,
5952+
"stdout",
5953+
[
5954+
`Collection: ${collectionName}`,
5955+
` Path: ${containerRoot}`,
5956+
` Pattern: **/*.md`,
5957+
` Include: yes (default)`,
5958+
].join("\n"),
5959+
);
5960+
return child;
5961+
}
5962+
return createMockChild();
5963+
});
5964+
5965+
const { manager } = await createManager({ mode: "full" });
5966+
await manager.close();
5967+
5968+
const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]);
5969+
const removeCall = commands.find(
5970+
(args) => args[0] === "collection" && args[1] === "remove" && args[2] === collectionName,
5971+
);
5972+
expect(removeCall).toBeDefined();
5973+
const addCall = commands.find((args) => {
5974+
if (args[0] !== "collection" || args[1] !== "add") {
5975+
return false;
5976+
}
5977+
const nameIdx = args.indexOf("--name");
5978+
return nameIdx >= 0 && args[nameIdx + 1] === collectionName;
5979+
});
5980+
expect(addCall).toBeDefined();
5981+
// Re-added at the host workspace root, not the stale container path.
5982+
expect(addCall?.[2]).toBe(newWorkspaceDir);
5983+
});
5984+
5985+
it("parseShownCollection extracts path and pattern from qmd collection show output", async () => {
5986+
// Unit test for the private parser — accessed via type cast to avoid exporting internals.
5987+
const { manager } = await createManager({ mode: "status" });
5988+
type WithParser = {
5989+
parseShownCollection: (output: string) => { path?: string; pattern?: string };
5990+
};
5991+
const parser = (manager as unknown as WithParser).parseShownCollection.bind(manager);
5992+
5993+
const sampleOutput = [
5994+
"Collection: memory-dir-example",
5995+
" Path: /home/node/.openclaw/teams/example-team/workspace-example/memory",
5996+
" Pattern: **/*.md",
5997+
" Include: yes (default)",
5998+
].join("\n");
5999+
6000+
const result = parser(sampleOutput);
6001+
expect(result.path).toBe("/home/node/.openclaw/teams/example-team/workspace-example/memory");
6002+
expect(result.pattern).toBe("**/*.md");
6003+
6004+
// Tolerant of missing fields.
6005+
expect(parser("")).toEqual({});
6006+
expect(parser("Collection: no-path-here\n Include: yes")).toEqual({});
6007+
6008+
// Path-only (no pattern line).
6009+
const pathOnly = parser("Collection: x\n Path: /some/path\n");
6010+
expect(pathOnly.path).toBe("/some/path");
6011+
expect(pathOnly.pattern).toBeUndefined();
6012+
6013+
await manager.close();
6014+
});
58516015
});
58526016

58536017
function createDeferred<T>() {

extensions/memory-core/src/memory/qmd-manager.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,36 @@ export class QmdMemoryManager implements MemorySearchManager {
657657
} catch {
658658
// ignore; older qmd versions might not support list --json.
659659
}
660+
661+
// `qmd collection list` never emits the filesystem path, so `shouldRebindCollection`
662+
// cannot detect a workspace move. Enrich the path for managed collections only
663+
// (bounded subprocess count) via `qmd collection show`, which does expose it.
664+
for (const collection of this.qmd.collections) {
665+
const entry = existing.get(collection.name);
666+
if (!entry || entry.path) {
667+
// Not listed, or path already present (future-proof qmd version or text parser).
668+
continue;
669+
}
670+
try {
671+
const showResult = await this.runQmd(["collection", "show", collection.name], {
672+
timeoutMs: this.qmd.update.commandTimeoutMs,
673+
});
674+
const shown = this.parseShownCollection(showResult.stdout);
675+
if (shown.path) {
676+
entry.path = shown.path;
677+
}
678+
// Only backfill pattern when the list parse left it absent; never overwrite a
679+
// pattern already extracted from `collection list` output (e.g. a changed pattern
680+
// detected via qmd text output would be lost if we clobber it here).
681+
if (shown.pattern && !entry.pattern) {
682+
entry.pattern = shown.pattern;
683+
}
684+
} catch {
685+
// If show fails (old qmd, timeout, missing collection), leave path undefined.
686+
// shouldRebindCollection preserves the safe defensive behavior in that case.
687+
}
688+
}
689+
660690
return existing;
661691
}
662692

@@ -981,6 +1011,28 @@ export class QmdMemoryManager implements MemorySearchManager {
9811011
return listed;
9821012
}
9831013

1014+
// Parses the output of `qmd collection show <name>`, which emits:
1015+
// Collection: <name>
1016+
// Path: <absolute-path>
1017+
// Pattern: <glob>
1018+
// Include: yes (default)
1019+
// This is the only qmd command that reliably surfaces the filesystem path.
1020+
private parseShownCollection(output: string): { path?: string; pattern?: string } {
1021+
const result: { path?: string; pattern?: string } = {};
1022+
for (const rawLine of output.split(/\r?\n/)) {
1023+
const pathMatch = /^\s*Path\s*:\s*(.+?)\s*$/.exec(rawLine);
1024+
if (pathMatch) {
1025+
result.path = pathMatch[1].trim();
1026+
continue;
1027+
}
1028+
const patternMatch = /^\s*Pattern\s*:\s*(.+?)\s*$/.exec(rawLine);
1029+
if (patternMatch) {
1030+
result.pattern = patternMatch[1].trim();
1031+
}
1032+
}
1033+
return result;
1034+
}
1035+
9841036
private shouldRebindCollection(collection: ManagedCollection, listed: ListedCollection): boolean {
9851037
if (!listed.path) {
9861038
if (typeof listed.pattern === "string" && listed.pattern !== collection.pattern) {

0 commit comments

Comments
 (0)