diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index a2c8a976be9e..f173d7747634 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -2874,6 +2874,107 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("aborts the in-flight mcporter search subprocess when the caller signal aborts", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, + }, + }, + } as OpenClawConfig; + + // The mcporter `call` child never closes on its own, so the only way the + // search can settle is the caller-owned abort signal reaching the mcporter + // subprocess via runMcporter -> runCliCommand and killing it. + let mcporterCallKill: ReturnType | undefined; + let mcporterCallChild: MockChild | undefined; + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (isMcporterCommand(cmd) && args[0] === "call") { + const child = createMockChild({ autoClose: false }); + const kill = vi.fn(() => { + // Mirror a real child exiting after SIGKILL so the close handler runs. + queueMicrotask(() => child.emit("close", null)); + }); + Object.assign(child, { kill }); + mcporterCallKill = kill; + mcporterCallChild = child; + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager(); + const controller = new AbortController(); + + const searchPromise = manager.search("test", { + sessionKey: "agent:main:slack:dm:u123", + signal: controller.signal, + }); + searchPromise.catch(() => undefined); + + await waitUntil(() => mcporterCallKill !== undefined); + expect(mcporterCallChild).toBeDefined(); + + controller.abort(new Error("memory_search timed out after 15s")); + + await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s"); + expect(mcporterCallKill).toHaveBeenCalledWith("SIGKILL"); + await manager.close(); + }); + + it("rejects the mcporter search before spawning a call subprocess when the caller signal is already aborted", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "query", + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + const child = createMockChild({ autoClose: false }); + if (isMcporterCommand(cmd) && args[0] === "call") { + emitAndClose(child, "stdout", JSON.stringify({ results: [] })); + return child; + } + emitAndClose(child, "stdout", "[]"); + return child; + }); + + const { manager } = await createManager(); + const controller = new AbortController(); + controller.abort(new Error("memory_search timed out after 15s")); + + const callsBefore = spawnMock.mock.calls.filter( + (call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call", + ).length; + + await expect( + manager.search("test", { + sessionKey: "agent:main:slack:dm:u123", + signal: controller.signal, + }), + ).rejects.toThrow("memory_search timed out after 15s"); + + const callsAfter = spawnMock.mock.calls.filter( + (call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call", + ).length; + expect(callsAfter).toBe(callsBefore); + await manager.close(); + }); + it("does not pass --no-rerank to direct query fallback from search mode", async () => { cfg = { ...cfg, diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 414b99a81d10..0d52d7b116dc 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -342,6 +342,7 @@ type QmdMcporterSearchParams = minScore: number; collection?: string; timeoutMs: number; + signal?: AbortSignal; } | { mcporter: ResolvedQmdMcporterConfig; @@ -353,6 +354,7 @@ type QmdMcporterSearchParams = minScore: number; collection?: string; timeoutMs: number; + signal?: AbortSignal; }; type QmdMcporterAcrossCollectionsParams = | { @@ -363,6 +365,7 @@ type QmdMcporterAcrossCollectionsParams = limit: number; minScore: number; collectionNames: string[]; + signal?: AbortSignal; } | { tool: BuiltinQmdMcpTool; @@ -372,6 +375,7 @@ type QmdMcporterAcrossCollectionsParams = limit: number; minScore: number; collectionNames: string[]; + signal?: AbortSignal; }; export class QmdMemoryManager implements MemorySearchManager { @@ -1353,6 +1357,7 @@ export class QmdMemoryManager implements MemorySearchManager { limit, minScore, collectionNames, + signal: searchSignal, }); } return await this.runQmdSearchViaMcporter({ @@ -1365,6 +1370,7 @@ export class QmdMemoryManager implements MemorySearchManager { minScore, collection: collectionNames[0], timeoutMs: this.qmd.limits.timeoutMs, + signal: searchSignal, }); } const tool = this.resolveQmdMcpTool(qmdSearchCommand); @@ -1377,6 +1383,7 @@ export class QmdMemoryManager implements MemorySearchManager { limit, minScore, collectionNames, + signal: searchSignal, }); } return await this.runQmdSearchViaMcporter({ @@ -1389,6 +1396,7 @@ export class QmdMemoryManager implements MemorySearchManager { minScore, collection: collectionNames[0], timeoutMs: this.qmd.limits.timeoutMs, + signal: searchSignal, }); } const collectionGroups = await this.resolveCollectionSearchGroups(collectionNames); @@ -2336,7 +2344,7 @@ export class QmdMemoryManager implements MemorySearchManager { private async runMcporter( args: string[], - opts?: { timeoutMs?: number }, + opts?: { timeoutMs?: number; signal?: AbortSignal }, ): Promise<{ stdout: string; stderr: string }> { const spawnInvocation = resolveCliSpawnInvocation({ command: "mcporter", @@ -2352,12 +2360,16 @@ export class QmdMemoryManager implements MemorySearchManager { cwd: this.workspaceDir, timeoutMs: opts?.timeoutMs, maxOutputChars: this.maxQmdOutputChars, + signal: opts?.signal, }); } private async runQmdSearchViaMcporter( params: QmdMcporterSearchParams, ): Promise { + if (params.signal?.aborted) { + throw asAbortError(params.signal); + } await this.ensureMcporterDaemonStarted(params.mcporter); // If the version is already known as v1 but we received a stale "query" tool name @@ -2414,7 +2426,10 @@ export class QmdMemoryManager implements MemorySearchManager { "--timeout", String(Math.max(0, params.timeoutMs)), ], - { timeoutMs: resolveQmdMcporterSearchProcessTimeoutMs(params.timeoutMs) }, + { + timeoutMs: resolveQmdMcporterSearchProcessTimeoutMs(params.timeoutMs), + signal: params.signal, + }, ); // If we got here with the v2 "query" tool, confirm v2 for future calls. if (useUnifiedQueryTool && this.qmdMcpToolVersion === null) { @@ -2443,6 +2458,7 @@ export class QmdMemoryManager implements MemorySearchManager { minScore: params.minScore, collection: params.collection, timeoutMs: params.timeoutMs, + signal: params.signal, }); } throw err; @@ -3470,6 +3486,7 @@ export class QmdMemoryManager implements MemorySearchManager { minScore: params.minScore, collection: collectionName, timeoutMs: this.qmd.limits.timeoutMs, + signal: params.signal, }) : await this.runQmdSearchViaMcporter({ mcporter: this.qmd.mcporter, @@ -3481,6 +3498,7 @@ export class QmdMemoryManager implements MemorySearchManager { minScore: params.minScore, collection: collectionName, timeoutMs: this.qmd.limits.timeoutMs, + signal: params.signal, }); for (const entry of parsed) { if (typeof entry.docid !== "string" || !entry.docid.trim()) {