@@ -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