Skip to content

Commit f56a79f

Browse files
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
1 parent e6e2407 commit f56a79f

File tree

2 files changed

+121
-84
lines changed

2 files changed

+121
-84
lines changed

src/memory/search-manager.test.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ describe("getMemorySearchManager caching", () => {
195195
expect(createQmdManagerMock).toHaveBeenCalledTimes(2);
196196
});
197197

198-
it("uses lightweight cached managers for status-only qmd requests", async () => {
198+
it("reuses cached qmd managers for status-only requests", async () => {
199199
const agentId = "status-agent";
200200
const cfg = createQmdCfg(agentId);
201201

@@ -209,18 +209,48 @@ describe("getMemorySearchManager caching", () => {
209209
provider: "qmd",
210210
model: "qmd",
211211
requestedProvider: "qmd",
212-
custom: {
213-
qmd: {
214-
lightweightStatus: true,
215-
},
216-
},
217212
});
218213
// eslint-disable-next-line @typescript-eslint/unbound-method
219-
expect(createQmdManagerMock).not.toHaveBeenCalled();
214+
expect(createQmdManagerMock).toHaveBeenCalledTimes(1);
220215
expect(mockMemoryIndexGet).not.toHaveBeenCalled();
221216
expect(second.manager).toBe(first.manager);
222217
});
223218

219+
it("reuses cached full qmd manager for status-only requests", async () => {
220+
const agentId = "status-reuses-full-agent";
221+
const cfg = createQmdCfg(agentId);
222+
223+
const full = await getMemorySearchManager({ cfg, agentId });
224+
const status = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
225+
226+
requireManager(full);
227+
requireManager(status);
228+
expect(status.manager).not.toBe(full.manager);
229+
// eslint-disable-next-line @typescript-eslint/unbound-method
230+
expect(createQmdManagerMock).toHaveBeenCalledTimes(1);
231+
await status.manager?.close?.();
232+
expect(mockPrimary.close).not.toHaveBeenCalled();
233+
234+
const fullAgain = await getMemorySearchManager({ cfg, agentId });
235+
expect(fullAgain.manager).toBe(full.manager);
236+
});
237+
238+
it("evicts closed cached status managers so later status requests get a fresh manager", async () => {
239+
const agentId = "status-eviction-agent";
240+
const cfg = createQmdCfg(agentId);
241+
242+
const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
243+
const firstManager = requireManager(first);
244+
await firstManager.close?.();
245+
246+
const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" });
247+
requireManager(second);
248+
249+
expect(second.manager).not.toBe(firstManager);
250+
// eslint-disable-next-line @typescript-eslint/unbound-method
251+
expect(createQmdManagerMock).toHaveBeenCalledTimes(2);
252+
});
253+
224254
it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => {
225255
const retryAgentId = "retry-agent-close";
226256
const {

src/memory/search-manager.ts

Lines changed: 84 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import os from "node:os";
2-
import path from "node:path";
3-
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
41
import type { OpenClawConfig } from "../config/config.js";
5-
import { resolveStateDir } from "../config/paths.js";
62
import { createSubsystemLogger } from "../logging/subsystem.js";
73
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
84
import type { ResolvedQmdConfig } from "./backend-config.js";
@@ -57,13 +53,13 @@ export async function getMemorySearchManager(params: {
5753
return { manager: cached };
5854
}
5955
if (statusOnly) {
60-
const manager = new QmdStatusOnlyManager({
61-
cfg: params.cfg,
62-
agentId: params.agentId,
63-
resolved: resolved.qmd,
64-
});
65-
QMD_MANAGER_CACHE.set(cacheKey, manager);
66-
return { manager };
56+
const fullCached = QMD_MANAGER_CACHE.get(`${baseCacheKey}:full`);
57+
if (fullCached) {
58+
// Status callers often close the manager they receive. Wrap the live
59+
// full manager with a no-op close so health/status probes do not tear
60+
// down the active QMD manager for the process.
61+
return { manager: new BorrowedMemoryManager(fullCached) };
62+
}
6763
}
6864
try {
6965
const { QmdMemoryManager } = await import("./qmd-manager.js");
@@ -75,8 +71,11 @@ export async function getMemorySearchManager(params: {
7571
});
7672
if (primary) {
7773
if (statusOnly) {
78-
QMD_MANAGER_CACHE.set(cacheKey, primary);
79-
return { manager: primary };
74+
const wrapper = new CachedStatusMemoryManager(primary, () => {
75+
QMD_MANAGER_CACHE.delete(cacheKey);
76+
});
77+
QMD_MANAGER_CACHE.set(cacheKey, wrapper);
78+
return { manager: wrapper };
8079
}
8180
const wrapper = new FallbackMemoryManager(
8281
{
@@ -109,87 +108,95 @@ export async function getMemorySearchManager(params: {
109108
}
110109
}
111110

112-
class QmdStatusOnlyManager implements MemorySearchManager {
113-
private readonly workspaceDir: string;
114-
private readonly indexPath: string;
115-
private readonly sourceSet: Set<"memory" | "sessions">;
111+
class BorrowedMemoryManager implements MemorySearchManager {
112+
constructor(private readonly inner: MemorySearchManager) {}
116113

117-
constructor(
118-
private readonly params: {
119-
cfg: OpenClawConfig;
120-
agentId: string;
121-
resolved: ResolvedQmdConfig;
122-
},
114+
async search(
115+
query: string,
116+
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
123117
) {
124-
this.workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
125-
const stateDir = resolveStateDir(process.env, os.homedir);
126-
this.indexPath = path.join(
127-
stateDir,
128-
"agents",
129-
params.agentId,
130-
"qmd",
131-
"xdg-cache",
132-
"qmd",
133-
"index.sqlite",
134-
);
135-
this.sourceSet = new Set(
136-
params.resolved.collections.map((collection) =>
137-
collection.kind === "sessions" ? "sessions" : "memory",
138-
),
139-
);
118+
return await this.inner.search(query, opts);
140119
}
141120

142-
async search(): Promise<never> {
143-
throw new Error("memory search unavailable in status-only mode");
121+
async readFile(params: { relPath: string; from?: number; lines?: number }) {
122+
return await this.inner.readFile(params);
144123
}
145124

146-
async readFile(): Promise<never> {
147-
throw new Error("memory read unavailable in status-only mode");
125+
status() {
126+
return this.inner.status();
127+
}
128+
129+
async sync(params?: {
130+
reason?: string;
131+
force?: boolean;
132+
sessionFiles?: string[];
133+
progress?: (update: MemorySyncProgressUpdate) => void;
134+
}) {
135+
await this.inner.sync?.(params);
136+
}
137+
138+
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
139+
return await this.inner.probeEmbeddingAvailability();
140+
}
141+
142+
async probeVectorAvailability() {
143+
return await this.inner.probeVectorAvailability();
144+
}
145+
146+
async close() {}
147+
}
148+
149+
class CachedStatusMemoryManager implements MemorySearchManager {
150+
private closed = false;
151+
152+
constructor(
153+
private readonly inner: MemorySearchManager,
154+
private readonly onClose: () => void,
155+
) {}
156+
157+
async search(
158+
query: string,
159+
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
160+
) {
161+
return await this.inner.search(query, opts);
162+
}
163+
164+
async readFile(params: { relPath: string; from?: number; lines?: number }) {
165+
return await this.inner.readFile(params);
148166
}
149167

150168
status() {
151-
return {
152-
backend: "qmd" as const,
153-
provider: "qmd",
154-
model: "qmd",
155-
requestedProvider: "qmd",
156-
files: 0,
157-
chunks: 0,
158-
dirty: false,
159-
workspaceDir: this.workspaceDir,
160-
dbPath: this.indexPath,
161-
sources: Array.from(this.sourceSet),
162-
vector: { enabled: true, available: true },
163-
batch: {
164-
enabled: false,
165-
failures: 0,
166-
limit: 0,
167-
wait: false,
168-
concurrency: 0,
169-
pollIntervalMs: 0,
170-
timeoutMs: 0,
171-
},
172-
custom: {
173-
qmd: {
174-
collections: this.params.resolved.collections.length,
175-
lastUpdateAt: null,
176-
lightweightStatus: true,
177-
},
178-
},
179-
};
169+
return this.inner.status();
180170
}
181171

182-
async sync(): Promise<void> {}
172+
async sync(params?: {
173+
reason?: string;
174+
force?: boolean;
175+
sessionFiles?: string[];
176+
progress?: (update: MemorySyncProgressUpdate) => void;
177+
}) {
178+
await this.inner.sync?.(params);
179+
}
183180

184181
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
185-
return { ok: true };
182+
return await this.inner.probeEmbeddingAvailability();
186183
}
187184

188-
async probeVectorAvailability(): Promise<boolean> {
189-
return true;
185+
async probeVectorAvailability() {
186+
return await this.inner.probeVectorAvailability();
190187
}
191188

192-
async close(): Promise<void> {}
189+
async close() {
190+
if (this.closed) {
191+
return;
192+
}
193+
this.closed = true;
194+
try {
195+
await this.inner.close?.();
196+
} finally {
197+
this.onClose();
198+
}
199+
}
193200
}
194201

195202
export async function closeAllMemorySearchManagers(): Promise<void> {

0 commit comments

Comments
 (0)