Memory/QMD: make status checks side-effect free

This commit is contained in:
Vignesh Natarajan
2026-02-14 15:41:16 -08:00
parent ceb934299b
commit c4dbcc3444
7 changed files with 76 additions and 9 deletions

View File

@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency. - Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.
- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. - Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`.
- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui. - Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks.
- Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier. - Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier.
- Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads. - Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads.
- Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn. - Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.

View File

@@ -253,8 +253,9 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
}> = []; }> = [];
for (const agentId of agentIds) { for (const agentId of agentIds) {
const managerPurpose = opts.index ? "default" : "status";
await withManager<MemoryManager>({ await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }), getManager: () => getMemorySearchManager({ cfg, agentId, purpose: managerPurpose }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) => onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),

View File

@@ -158,7 +158,7 @@ export async function scanStatus(
return null; return null;
} }
const agentId = agentStatus.defaultId ?? "main"; const agentId = agentStatus.defaultId ?? "main";
const { manager } = await getMemorySearchManager({ cfg, agentId }); const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
if (!manager) { if (!manager) {
return null; return null;
} }

View File

@@ -189,6 +189,26 @@ describe("QmdMemoryManager", () => {
await manager?.close(); await manager?.close();
}); });
it("skips qmd command side effects in status mode initialization", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "5m", debounceMs: 60_000, onBoot: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved, mode: "status" });
expect(manager).toBeTruthy();
expect(spawnMock).not.toHaveBeenCalled();
await manager?.close();
});
it("can be configured to block startup on boot update", async () => { it("can be configured to block startup on boot update", async () => {
cfg = { cfg = {
...cfg, ...cfg,

View File

@@ -44,18 +44,21 @@ type SessionExporterConfig = {
collectionName: string; collectionName: string;
}; };
type QmdManagerMode = "full" | "status";
export class QmdMemoryManager implements MemorySearchManager { export class QmdMemoryManager implements MemorySearchManager {
static async create(params: { static async create(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
agentId: string; agentId: string;
resolved: ResolvedMemoryBackendConfig; resolved: ResolvedMemoryBackendConfig;
mode?: QmdManagerMode;
}): Promise<QmdMemoryManager | null> { }): Promise<QmdMemoryManager | null> {
const resolved = params.resolved.qmd; const resolved = params.resolved.qmd;
if (!resolved) { if (!resolved) {
return null; return null;
} }
const manager = new QmdMemoryManager({ cfg: params.cfg, agentId: params.agentId, resolved }); const manager = new QmdMemoryManager({ cfg: params.cfg, agentId: params.agentId, resolved });
await manager.initialize(); await manager.initialize(params.mode ?? "full");
return manager; return manager;
} }
@@ -143,7 +146,12 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
} }
private async initialize(): Promise<void> { private async initialize(mode: QmdManagerMode): Promise<void> {
this.bootstrapCollections();
if (mode === "status") {
return;
}
await fs.mkdir(this.xdgConfigHome, { recursive: true }); await fs.mkdir(this.xdgConfigHome, { recursive: true });
await fs.mkdir(this.xdgCacheHome, { recursive: true }); await fs.mkdir(this.xdgCacheHome, { recursive: true });
await fs.mkdir(path.dirname(this.indexPath), { recursive: true }); await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
@@ -156,7 +164,6 @@ export class QmdMemoryManager implements MemorySearchManager {
// isolated while models are shared. // isolated while models are shared.
await this.symlinkSharedModels(); await this.symlinkSharedModels();
this.bootstrapCollections();
await this.ensureCollections(); await this.ensureCollections();
if (this.qmd.update.onBoot) { if (this.qmd.update.onBoot) {

View File

@@ -53,6 +53,8 @@ const fallbackManager = {
close: vi.fn(async () => {}), close: vi.fn(async () => {}),
}; };
const mockMemoryIndexGet = vi.fn(async () => fallbackManager);
vi.mock("./qmd-manager.js", () => ({ vi.mock("./qmd-manager.js", () => ({
QmdMemoryManager: { QmdMemoryManager: {
create: vi.fn(async () => mockPrimary), create: vi.fn(async () => mockPrimary),
@@ -61,7 +63,7 @@ vi.mock("./qmd-manager.js", () => ({
vi.mock("./manager.js", () => ({ vi.mock("./manager.js", () => ({
MemoryIndexManager: { MemoryIndexManager: {
get: vi.fn(async () => fallbackManager), get: mockMemoryIndexGet,
}, },
})); }));
@@ -83,6 +85,8 @@ beforeEach(() => {
fallbackManager.probeEmbeddingAvailability.mockClear(); fallbackManager.probeEmbeddingAvailability.mockClear();
fallbackManager.probeVectorAvailability.mockClear(); fallbackManager.probeVectorAvailability.mockClear();
fallbackManager.close.mockClear(); fallbackManager.close.mockClear();
mockMemoryIndexGet.mockReset();
mockMemoryIndexGet.mockResolvedValue(fallbackManager);
QmdMemoryManager.create.mockClear(); QmdMemoryManager.create.mockClear();
}); });
@@ -126,6 +130,32 @@ describe("getMemorySearchManager caching", () => {
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2); expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
}); });
it("does not cache status-only qmd managers", async () => {
const agentId = "status-agent";
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }] },
} as const;
const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
expect(first.manager).toBeTruthy();
expect(second.manager).toBeTruthy();
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ agentId, mode: "status" }),
);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ agentId, mode: "status" }),
);
});
it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => { it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => {
const retryAgentId = "retry-agent-close"; const retryAgentId = "retry-agent-close";
const cfg = { const cfg = {

View File

@@ -19,13 +19,17 @@ export type MemorySearchManagerResult = {
export async function getMemorySearchManager(params: { export async function getMemorySearchManager(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
agentId: string; agentId: string;
purpose?: "default" | "status";
}): Promise<MemorySearchManagerResult> { }): Promise<MemorySearchManagerResult> {
const resolved = resolveMemoryBackendConfig(params); const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) { if (resolved.backend === "qmd" && resolved.qmd) {
const statusOnly = params.purpose === "status";
const cacheKey = buildQmdCacheKey(params.agentId, resolved.qmd); const cacheKey = buildQmdCacheKey(params.agentId, resolved.qmd);
const cached = QMD_MANAGER_CACHE.get(cacheKey); if (!statusOnly) {
if (cached) { const cached = QMD_MANAGER_CACHE.get(cacheKey);
return { manager: cached }; if (cached) {
return { manager: cached };
}
} }
try { try {
const { QmdMemoryManager } = await import("./qmd-manager.js"); const { QmdMemoryManager } = await import("./qmd-manager.js");
@@ -33,8 +37,12 @@ export async function getMemorySearchManager(params: {
cfg: params.cfg, cfg: params.cfg,
agentId: params.agentId, agentId: params.agentId,
resolved, resolved,
mode: statusOnly ? "status" : "full",
}); });
if (primary) { if (primary) {
if (statusOnly) {
return { manager: primary };
}
const wrapper = new FallbackMemoryManager( const wrapper = new FallbackMemoryManager(
{ {
primary, primary,