fix: report qmd status counts from real qmd manager (#53683) (thanks @neeravmakwana)

* fix(memory): report qmd status counts from index

* fix(memory): reuse full qmd manager for status

* fix(memory): harden qmd status manager lifecycle
This commit is contained in:
Neerav Makwana 2026-03-24 09:40:20 -04:00 committed by GitHub
parent e6e2407cee
commit f56a79f838
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 123 additions and 86 deletions

View File

@ -195,7 +195,7 @@ describe("getMemorySearchManager caching", () => {
expect(createQmdManagerMock).toHaveBeenCalledTimes(2);
});
it("uses lightweight cached managers for status-only qmd requests", async () => {
it("reuses cached qmd managers for status-only requests", async () => {
const agentId = "status-agent";
const cfg = createQmdCfg(agentId);
@ -209,18 +209,48 @@ describe("getMemorySearchManager caching", () => {
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
custom: {
qmd: {
lightweightStatus: true,
},
},
});
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(createQmdManagerMock).not.toHaveBeenCalled();
expect(createQmdManagerMock).toHaveBeenCalledTimes(1);
expect(mockMemoryIndexGet).not.toHaveBeenCalled();
expect(second.manager).toBe(first.manager);
});
it("reuses cached full qmd manager for status-only requests", async () => {
const agentId = "status-reuses-full-agent";
const cfg = createQmdCfg(agentId);
const full = await getMemorySearchManager({ cfg, agentId });
const status = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
requireManager(full);
requireManager(status);
expect(status.manager).not.toBe(full.manager);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(createQmdManagerMock).toHaveBeenCalledTimes(1);
await status.manager?.close?.();
expect(mockPrimary.close).not.toHaveBeenCalled();
const fullAgain = await getMemorySearchManager({ cfg, agentId });
expect(fullAgain.manager).toBe(full.manager);
});
it("evicts closed cached status managers so later status requests get a fresh manager", async () => {
const agentId = "status-eviction-agent";
const cfg = createQmdCfg(agentId);
const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
const firstManager = requireManager(first);
await firstManager.close?.();
const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
requireManager(second);
expect(second.manager).not.toBe(firstManager);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(createQmdManagerMock).toHaveBeenCalledTimes(2);
});
it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => {
const retryAgentId = "retry-agent-close";
const {

View File

@ -1,8 +1,4 @@
import os from "node:os";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import type { ResolvedQmdConfig } from "./backend-config.js";
@ -57,13 +53,13 @@ export async function getMemorySearchManager(params: {
return { manager: cached };
}
if (statusOnly) {
const manager = new QmdStatusOnlyManager({
cfg: params.cfg,
agentId: params.agentId,
resolved: resolved.qmd,
});
QMD_MANAGER_CACHE.set(cacheKey, manager);
return { manager };
const fullCached = QMD_MANAGER_CACHE.get(`${baseCacheKey}:full`);
if (fullCached) {
// Status callers often close the manager they receive. Wrap the live
// full manager with a no-op close so health/status probes do not tear
// down the active QMD manager for the process.
return { manager: new BorrowedMemoryManager(fullCached) };
}
}
try {
const { QmdMemoryManager } = await import("./qmd-manager.js");
@ -75,8 +71,11 @@ export async function getMemorySearchManager(params: {
});
if (primary) {
if (statusOnly) {
QMD_MANAGER_CACHE.set(cacheKey, primary);
return { manager: primary };
const wrapper = new CachedStatusMemoryManager(primary, () => {
QMD_MANAGER_CACHE.delete(cacheKey);
});
QMD_MANAGER_CACHE.set(cacheKey, wrapper);
return { manager: wrapper };
}
const wrapper = new FallbackMemoryManager(
{
@ -109,87 +108,95 @@ export async function getMemorySearchManager(params: {
}
}
class QmdStatusOnlyManager implements MemorySearchManager {
private readonly workspaceDir: string;
private readonly indexPath: string;
private readonly sourceSet: Set<"memory" | "sessions">;
class BorrowedMemoryManager implements MemorySearchManager {
constructor(private readonly inner: MemorySearchManager) {}
constructor(
private readonly params: {
cfg: OpenClawConfig;
agentId: string;
resolved: ResolvedQmdConfig;
},
async search(
query: string,
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
) {
this.workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
const stateDir = resolveStateDir(process.env, os.homedir);
this.indexPath = path.join(
stateDir,
"agents",
params.agentId,
"qmd",
"xdg-cache",
"qmd",
"index.sqlite",
);
this.sourceSet = new Set(
params.resolved.collections.map((collection) =>
collection.kind === "sessions" ? "sessions" : "memory",
),
);
return await this.inner.search(query, opts);
}
async search(): Promise<never> {
throw new Error("memory search unavailable in status-only mode");
}
async readFile(): Promise<never> {
throw new Error("memory read unavailable in status-only mode");
async readFile(params: { relPath: string; from?: number; lines?: number }) {
return await this.inner.readFile(params);
}
status() {
return {
backend: "qmd" as const,
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: this.workspaceDir,
dbPath: this.indexPath,
sources: Array.from(this.sourceSet),
vector: { enabled: true, available: true },
batch: {
enabled: false,
failures: 0,
limit: 0,
wait: false,
concurrency: 0,
pollIntervalMs: 0,
timeoutMs: 0,
},
custom: {
qmd: {
collections: this.params.resolved.collections.length,
lastUpdateAt: null,
lightweightStatus: true,
},
},
};
return this.inner.status();
}
async sync(): Promise<void> {}
async sync(params?: {
reason?: string;
force?: boolean;
sessionFiles?: string[];
progress?: (update: MemorySyncProgressUpdate) => void;
}) {
await this.inner.sync?.(params);
}
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
return { ok: true };
return await this.inner.probeEmbeddingAvailability();
}
async probeVectorAvailability(): Promise<boolean> {
return true;
async probeVectorAvailability() {
return await this.inner.probeVectorAvailability();
}
async close(): Promise<void> {}
async close() {}
}
class CachedStatusMemoryManager implements MemorySearchManager {
private closed = false;
constructor(
private readonly inner: MemorySearchManager,
private readonly onClose: () => void,
) {}
async search(
query: string,
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
) {
return await this.inner.search(query, opts);
}
async readFile(params: { relPath: string; from?: number; lines?: number }) {
return await this.inner.readFile(params);
}
status() {
return this.inner.status();
}
async sync(params?: {
reason?: string;
force?: boolean;
sessionFiles?: string[];
progress?: (update: MemorySyncProgressUpdate) => void;
}) {
await this.inner.sync?.(params);
}
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
return await this.inner.probeEmbeddingAvailability();
}
async probeVectorAvailability() {
return await this.inner.probeVectorAvailability();
}
async close() {
if (this.closed) {
return;
}
this.closed = true;
try {
await this.inner.close?.();
} finally {
this.onClose();
}
}
}
export async function closeAllMemorySearchManagers(): Promise<void> {