mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix (memory/qmd): rebind drifted managed collection paths
This commit is contained in:
@@ -290,6 +290,107 @@ describe("QmdMemoryManager", () => {
|
||||
await manager?.close();
|
||||
});
|
||||
|
||||
it("rebinds sessions collection when existing collection path targets another agent", async () => {
|
||||
const devAgentId = "dev";
|
||||
const devWorkspaceDir = path.join(tmpRoot, "workspace-dev");
|
||||
await fs.mkdir(devWorkspaceDir);
|
||||
cfg = {
|
||||
...cfg,
|
||||
agents: {
|
||||
list: [
|
||||
{ id: agentId, default: true, workspace: workspaceDir },
|
||||
{ id: devAgentId, workspace: devWorkspaceDir },
|
||||
],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: devWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
sessions: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const wrongSessionsPath = path.join(stateDir, "agents", agentId, "qmd", "sessions");
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "collection" && args[1] === "list") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([{ name: "sessions", path: wrongSessionsPath, mask: "**/*.md" }]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: devAgentId });
|
||||
const manager = await QmdMemoryManager.create({ cfg, agentId: devAgentId, resolved, mode: "full" });
|
||||
expect(manager).toBeTruthy();
|
||||
await manager?.close();
|
||||
|
||||
const commands = spawnMock.mock.calls.map((call) => call[1] as string[]);
|
||||
const removeSessions = commands.find(
|
||||
(args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions",
|
||||
);
|
||||
expect(removeSessions).toBeDefined();
|
||||
|
||||
const addSessions = commands.find((args) => {
|
||||
if (args[0] !== "collection" || args[1] !== "add") {
|
||||
return false;
|
||||
}
|
||||
const nameIdx = args.indexOf("--name");
|
||||
return nameIdx >= 0 && args[nameIdx + 1] === "sessions";
|
||||
});
|
||||
expect(addSessions).toBeDefined();
|
||||
expect(addSessions?.[2]).toBe(path.join(stateDir, "agents", devAgentId, "qmd", "sessions"));
|
||||
});
|
||||
|
||||
it("rebinds sessions collection when qmd only reports collection names", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
sessions: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "collection" && args[1] === "list") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", JSON.stringify(["workspace", "sessions"]));
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
await manager.close();
|
||||
|
||||
const commands = spawnMock.mock.calls.map((call) => call[1] as string[]);
|
||||
const removeSessions = commands.find(
|
||||
(args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions",
|
||||
);
|
||||
expect(removeSessions).toBeDefined();
|
||||
|
||||
const addSessions = commands.find((args) => {
|
||||
if (args[0] !== "collection" || args[1] !== "add") {
|
||||
return false;
|
||||
}
|
||||
const nameIdx = args.indexOf("--name");
|
||||
return nameIdx >= 0 && args[nameIdx + 1] === "sessions";
|
||||
});
|
||||
expect(addSessions).toBeDefined();
|
||||
});
|
||||
|
||||
it("times out qmd update during sync when configured", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
|
||||
@@ -45,6 +45,11 @@ type SessionExporterConfig = {
|
||||
collectionName: string;
|
||||
};
|
||||
|
||||
type ListedCollection = {
|
||||
path?: string;
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
type QmdManagerMode = "full" | "status";
|
||||
|
||||
export class QmdMemoryManager implements MemorySearchManager {
|
||||
@@ -203,7 +208,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 Set<string>();
|
||||
const existing = new Map<string, ListedCollection>();
|
||||
try {
|
||||
const result = await this.runQmd(["collection", "list", "--json"], {
|
||||
timeoutMs: this.qmd.update.commandTimeoutMs,
|
||||
@@ -212,11 +217,22 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const entry of parsed) {
|
||||
if (typeof entry === "string") {
|
||||
existing.add(entry);
|
||||
existing.set(entry, {});
|
||||
} else if (entry && typeof entry === "object") {
|
||||
const name = (entry as { name?: unknown }).name;
|
||||
if (typeof name === "string") {
|
||||
existing.add(name);
|
||||
const listedPath = (entry as { path?: unknown }).path;
|
||||
const listedPattern = (entry as { pattern?: unknown; mask?: unknown }).pattern;
|
||||
const listedMask = (entry as { mask?: unknown }).mask;
|
||||
existing.set(name, {
|
||||
path: typeof listedPath === "string" ? listedPath : undefined,
|
||||
pattern:
|
||||
typeof listedPattern === "string"
|
||||
? listedPattern
|
||||
: typeof listedMask === "string"
|
||||
? listedMask
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,9 +242,20 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
|
||||
for (const collection of this.qmd.collections) {
|
||||
if (existing.has(collection.name)) {
|
||||
const listed = existing.get(collection.name);
|
||||
if (listed && !this.shouldRebindCollection(collection, listed)) {
|
||||
continue;
|
||||
}
|
||||
if (listed) {
|
||||
try {
|
||||
await this.removeCollection(collection.name);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!this.isCollectionMissingError(message)) {
|
||||
log.warn(`qmd collection remove failed for ${collection.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.addCollection(collection.path, collection.name, collection.pattern);
|
||||
} catch (err) {
|
||||
@@ -265,6 +292,35 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
});
|
||||
}
|
||||
|
||||
private shouldRebindCollection(
|
||||
collection: { kind: string; path: string; pattern: string },
|
||||
listed: ListedCollection,
|
||||
): boolean {
|
||||
if (!listed.path) {
|
||||
// Older qmd versions may only return names from `collection list --json`.
|
||||
// Force sessions collections to rebind so per-agent session export paths stay isolated.
|
||||
return collection.kind === "sessions";
|
||||
}
|
||||
if (!this.pathsMatch(listed.path, collection.path)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof listed.pattern === "string" && listed.pattern !== collection.pattern) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private pathsMatch(left: string, right: string): boolean {
|
||||
const normalize = (value: string): string => {
|
||||
const resolved = path.isAbsolute(value)
|
||||
? path.resolve(value)
|
||||
: path.resolve(this.workspaceDir, value);
|
||||
const normalized = path.normalize(resolved);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
};
|
||||
return normalize(left) === normalize(right);
|
||||
}
|
||||
|
||||
private shouldRepairNullByteCollectionError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user