Skip to content

Commit 8c356ff

Browse files
authored
Merge fe52ded into 2920dc3
2 parents 2920dc3 + fe52ded commit 8c356ff

4 files changed

Lines changed: 433 additions & 37 deletions

File tree

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2721,6 +2721,260 @@ describe("QmdMemoryManager", () => {
27212721
await manager.close();
27222722
});
27232723

2724+
it("aborts the in-flight qmd search subprocess when the caller signal aborts", async () => {
2725+
cfg = {
2726+
...cfg,
2727+
memory: {
2728+
backend: "qmd",
2729+
qmd: {
2730+
includeDefaultMemory: false,
2731+
searchMode: "query",
2732+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2733+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
2734+
},
2735+
},
2736+
} as OpenClawConfig;
2737+
2738+
// The query child never closes on its own so the only way the search can
2739+
// settle is the caller-owned abort signal killing the subprocess.
2740+
let queryChildKill: ReturnType<typeof vi.fn> | undefined;
2741+
let queryChild: MockChild | undefined;
2742+
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
2743+
if (args[0] === "query") {
2744+
const child = createMockChild({ autoClose: false });
2745+
const kill = vi.fn(() => {
2746+
// Mirror a real child exiting after SIGKILL so the close handler runs.
2747+
queueMicrotask(() => child.emit("close", null));
2748+
});
2749+
Object.assign(child, { kill });
2750+
queryChildKill = kill;
2751+
queryChild = child;
2752+
return child;
2753+
}
2754+
return createMockChild();
2755+
});
2756+
2757+
const { manager } = await createManager();
2758+
const controller = new AbortController();
2759+
2760+
const searchPromise = manager.search("test", {
2761+
sessionKey: "agent:main:slack:dm:u123",
2762+
signal: controller.signal,
2763+
});
2764+
searchPromise.catch(() => undefined);
2765+
2766+
await waitUntil(() => queryChildKill !== undefined);
2767+
expect(queryChild).toBeDefined();
2768+
2769+
controller.abort(new Error("memory_search timed out after 15s"));
2770+
2771+
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
2772+
expect(queryChildKill).toHaveBeenCalledWith("SIGKILL");
2773+
await manager.close();
2774+
});
2775+
2776+
it("rejects the qmd search before spawning when the caller signal is already aborted", async () => {
2777+
cfg = {
2778+
...cfg,
2779+
memory: {
2780+
backend: "qmd",
2781+
qmd: {
2782+
includeDefaultMemory: false,
2783+
searchMode: "query",
2784+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2785+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
2786+
},
2787+
},
2788+
} as OpenClawConfig;
2789+
2790+
const { manager } = await createManager();
2791+
const controller = new AbortController();
2792+
controller.abort(new Error("memory_search timed out after 15s"));
2793+
2794+
const callsBefore = spawnMock.mock.calls.filter(
2795+
(call: unknown[]) => (call[1] as string[])?.[0] === "query",
2796+
).length;
2797+
2798+
await expect(
2799+
manager.search("test", {
2800+
sessionKey: "agent:main:slack:dm:u123",
2801+
signal: controller.signal,
2802+
}),
2803+
).rejects.toThrow("memory_search timed out after 15s");
2804+
2805+
const callsAfter = spawnMock.mock.calls.filter(
2806+
(call: unknown[]) => (call[1] as string[])?.[0] === "query",
2807+
).length;
2808+
expect(callsAfter).toBe(callsBefore);
2809+
await manager.close();
2810+
});
2811+
2812+
it("aborts the in-flight grouped qmd search subprocess when the caller signal aborts", async () => {
2813+
cfg = {
2814+
...cfg,
2815+
memory: {
2816+
backend: "qmd",
2817+
qmd: {
2818+
includeDefaultMemory: false,
2819+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2820+
sessions: { enabled: true },
2821+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
2822+
},
2823+
},
2824+
} as OpenClawConfig;
2825+
2826+
// Mixed memory/session sources route the search through
2827+
// runQueryAcrossCollectionGroups. The first grouped search child never
2828+
// closes on its own, so the only way the search can settle is the
2829+
// caller-owned abort signal reaching the grouped subprocess and killing it.
2830+
let groupedChildKill: ReturnType<typeof vi.fn> | undefined;
2831+
let groupedChild: MockChild | undefined;
2832+
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
2833+
if (args[0] === "--help") {
2834+
const child = createMockChild({ autoClose: false });
2835+
emitAndClose(
2836+
child,
2837+
"stdout",
2838+
"-c, --collection <name> Filter by one or more collections",
2839+
);
2840+
return child;
2841+
}
2842+
if (args[0] === "search") {
2843+
const child = createMockChild({ autoClose: false });
2844+
const kill = vi.fn(() => {
2845+
// Mirror a real child exiting after SIGKILL so the close handler runs.
2846+
queueMicrotask(() => child.emit("close", null));
2847+
});
2848+
Object.assign(child, { kill });
2849+
if (!groupedChildKill) {
2850+
groupedChildKill = kill;
2851+
groupedChild = child;
2852+
}
2853+
return child;
2854+
}
2855+
return createMockChild();
2856+
});
2857+
2858+
const { manager } = await createManager();
2859+
const controller = new AbortController();
2860+
2861+
const searchPromise = manager.search("test", {
2862+
sessionKey: "agent:main:slack:dm:u123",
2863+
signal: controller.signal,
2864+
});
2865+
searchPromise.catch(() => undefined);
2866+
2867+
await waitUntil(() => groupedChildKill !== undefined);
2868+
expect(groupedChild).toBeDefined();
2869+
2870+
controller.abort(new Error("memory_search timed out after 15s"));
2871+
2872+
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
2873+
expect(groupedChildKill).toHaveBeenCalledWith("SIGKILL");
2874+
await manager.close();
2875+
});
2876+
2877+
it("aborts the in-flight mcporter search subprocess when the caller signal aborts", async () => {
2878+
cfg = {
2879+
...cfg,
2880+
memory: {
2881+
backend: "qmd",
2882+
qmd: {
2883+
includeDefaultMemory: false,
2884+
searchMode: "query",
2885+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2886+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
2887+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
2888+
},
2889+
},
2890+
} as OpenClawConfig;
2891+
2892+
// The mcporter `call` child never closes on its own, so the only way the
2893+
// search can settle is the caller-owned abort signal reaching the mcporter
2894+
// subprocess via runMcporter -> runCliCommand and killing it.
2895+
let mcporterCallKill: ReturnType<typeof vi.fn> | undefined;
2896+
let mcporterCallChild: MockChild | undefined;
2897+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
2898+
if (isMcporterCommand(cmd) && args[0] === "call") {
2899+
const child = createMockChild({ autoClose: false });
2900+
const kill = vi.fn(() => {
2901+
// Mirror a real child exiting after SIGKILL so the close handler runs.
2902+
queueMicrotask(() => child.emit("close", null));
2903+
});
2904+
Object.assign(child, { kill });
2905+
mcporterCallKill = kill;
2906+
mcporterCallChild = child;
2907+
return child;
2908+
}
2909+
return createMockChild();
2910+
});
2911+
2912+
const { manager } = await createManager();
2913+
const controller = new AbortController();
2914+
2915+
const searchPromise = manager.search("test", {
2916+
sessionKey: "agent:main:slack:dm:u123",
2917+
signal: controller.signal,
2918+
});
2919+
searchPromise.catch(() => undefined);
2920+
2921+
await waitUntil(() => mcporterCallKill !== undefined);
2922+
expect(mcporterCallChild).toBeDefined();
2923+
2924+
controller.abort(new Error("memory_search timed out after 15s"));
2925+
2926+
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
2927+
expect(mcporterCallKill).toHaveBeenCalledWith("SIGKILL");
2928+
await manager.close();
2929+
});
2930+
2931+
it("rejects the mcporter search before spawning a call subprocess when the caller signal is already aborted", async () => {
2932+
cfg = {
2933+
...cfg,
2934+
memory: {
2935+
backend: "qmd",
2936+
qmd: {
2937+
includeDefaultMemory: false,
2938+
searchMode: "query",
2939+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2940+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
2941+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
2942+
},
2943+
},
2944+
} as OpenClawConfig;
2945+
2946+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
2947+
const child = createMockChild({ autoClose: false });
2948+
if (isMcporterCommand(cmd) && args[0] === "call") {
2949+
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
2950+
return child;
2951+
}
2952+
emitAndClose(child, "stdout", "[]");
2953+
return child;
2954+
});
2955+
2956+
const { manager } = await createManager();
2957+
const controller = new AbortController();
2958+
controller.abort(new Error("memory_search timed out after 15s"));
2959+
2960+
const callsBefore = spawnMock.mock.calls.filter(
2961+
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call",
2962+
).length;
2963+
2964+
await expect(
2965+
manager.search("test", {
2966+
sessionKey: "agent:main:slack:dm:u123",
2967+
signal: controller.signal,
2968+
}),
2969+
).rejects.toThrow("memory_search timed out after 15s");
2970+
2971+
const callsAfter = spawnMock.mock.calls.filter(
2972+
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call",
2973+
).length;
2974+
expect(callsAfter).toBe(callsBefore);
2975+
await manager.close();
2976+
});
2977+
27242978
it("does not pass --no-rerank to direct query fallback from search mode", async () => {
27252979
cfg = {
27262980
...cfg,

0 commit comments

Comments
 (0)