Skip to content

Commit c7d8699

Browse files
committed
fix(memory): harden qmd status manager lifecycle
1 parent 08a0bac commit c7d8699

File tree

2 files changed

+122
-7
lines changed

2 files changed

+122
-7
lines changed

src/memory/search-manager.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,30 @@ describe("getMemorySearchManager caching", () => {
225225

226226
requireManager(full);
227227
requireManager(status);
228-
expect(status.manager).toBe(full.manager);
228+
expect(status.manager).not.toBe(full.manager);
229229
// eslint-disable-next-line @typescript-eslint/unbound-method
230230
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);
231252
});
232253

233254
it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => {

src/memory/search-manager.ts

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ export async function getMemorySearchManager(params: {
5353
return { manager: cached };
5454
}
5555
if (statusOnly) {
56-
// Reuse an existing full manager for status requests to avoid duplicate
57-
// QMD managers and extra sqlite handles for the same agent/config.
5856
const fullCached = QMD_MANAGER_CACHE.get(`${baseCacheKey}:full`);
5957
if (fullCached) {
60-
QMD_MANAGER_CACHE.set(cacheKey, fullCached);
61-
return { manager: 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) };
6262
}
6363
}
6464
try {
@@ -71,8 +71,11 @@ export async function getMemorySearchManager(params: {
7171
});
7272
if (primary) {
7373
if (statusOnly) {
74-
QMD_MANAGER_CACHE.set(cacheKey, primary);
75-
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 };
7679
}
7780
const wrapper = new FallbackMemoryManager(
7881
{
@@ -105,6 +108,97 @@ export async function getMemorySearchManager(params: {
105108
}
106109
}
107110

111+
class BorrowedMemoryManager implements MemorySearchManager {
112+
constructor(private readonly inner: MemorySearchManager) {}
113+
114+
async search(
115+
query: string,
116+
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
117+
) {
118+
return await this.inner.search(query, opts);
119+
}
120+
121+
async readFile(params: { relPath: string; from?: number; lines?: number }) {
122+
return await this.inner.readFile(params);
123+
}
124+
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);
166+
}
167+
168+
status() {
169+
return this.inner.status();
170+
}
171+
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+
}
180+
181+
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
182+
return await this.inner.probeEmbeddingAvailability();
183+
}
184+
185+
async probeVectorAvailability() {
186+
return await this.inner.probeVectorAvailability();
187+
}
188+
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+
}
200+
}
201+
108202
export async function closeAllMemorySearchManagers(): Promise<void> {
109203
const managers = Array.from(QMD_MANAGER_CACHE.values());
110204
QMD_MANAGER_CACHE.clear();

0 commit comments

Comments
 (0)