@@ -23,7 +23,10 @@ import type { SendMessageParams } from "../session-manager.js";
2323// outer variable so each test can swap the SDK message stream.
2424let scenario : SDKMessage [ ] = [ ] ;
2525let hangAfterScenario = false ;
26+ let queryCloseCount = 0 ;
27+ const releaseHangingQueries = new Set < ( ) => void > ( ) ;
2628const previousBgDrainTimeout = process . env . HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS ;
29+ const LONG_BG_DRAIN_TIMEOUT_MS = "60000" ;
2730
2831function makeQuery ( messages : SDKMessage [ ] ) {
2932 let closed = false ;
@@ -47,14 +50,21 @@ function makeQuery(messages: SDKMessage[]) {
4750 const heartbeat = setInterval ( ( ) => { } , 1000 ) ;
4851 try {
4952 await new Promise < void > ( ( resolve ) => {
50- releaseHang = resolve ;
53+ const release = ( ) => {
54+ releaseHangingQueries . delete ( release ) ;
55+ resolve ( ) ;
56+ } ;
57+ releaseHang = release ;
58+ releaseHangingQueries . add ( release ) ;
5159 } ) ;
5260 } finally {
5361 clearInterval ( heartbeat ) ;
62+ if ( releaseHang ) releaseHangingQueries . delete ( releaseHang ) ;
5463 }
5564 }
5665 } ,
5766 close ( ) {
67+ queryCloseCount += 1 ;
5868 closed = true ;
5969 releaseHang ?.( ) ;
6070 } ,
@@ -159,6 +169,17 @@ function taskStarted(taskId = "t1"): SDKMessage {
159169 } as unknown as SDKMessage ;
160170}
161171
172+ function taskUpdated ( taskId = "t1" , status = "completed" ) : SDKMessage {
173+ return {
174+ type : "system" ,
175+ subtype : "task_updated" ,
176+ task_id : taskId ,
177+ patch : { status } ,
178+ session_id : "s1" ,
179+ uuid : `tu-${ taskId } -${ status } ` ,
180+ } as unknown as SDKMessage ;
181+ }
182+
162183function baseParams ( ) : SendMessageParams {
163184 return {
164185 sessionId : "s1" ,
@@ -179,15 +200,38 @@ beforeAll(async () => {
179200} ) ;
180201
181202afterEach ( ( ) => {
203+ for ( const release of releaseHangingQueries ) release ( ) ;
204+ releaseHangingQueries . clear ( ) ;
182205 scenario = [ ] ;
183206 hangAfterScenario = false ;
207+ queryCloseCount = 0 ;
184208 if ( previousBgDrainTimeout === undefined ) {
185209 delete process . env . HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS ;
186210 } else {
187211 process . env . HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS = previousBgDrainTimeout ;
188212 }
189213} ) ;
190214
215+ async function expectSettles < T > (
216+ promise : Promise < T > ,
217+ label : string ,
218+ ) : Promise < T > {
219+ let timeout : ReturnType < typeof setTimeout > | undefined ;
220+ try {
221+ return await Promise . race ( [
222+ promise ,
223+ new Promise < never > ( ( _resolve , reject ) => {
224+ timeout = setTimeout (
225+ ( ) => reject ( new Error ( `${ label } timed out` ) ) ,
226+ 500 ,
227+ ) ;
228+ } ) ,
229+ ] ) ;
230+ } finally {
231+ if ( timeout ) clearTimeout ( timeout ) ;
232+ }
233+ }
234+
191235describe ( "ClaudeSessionManager backgrounded-task resume (#891)" , ( ) => {
192236 test ( "filters the pause result, resumes, and ends exactly once" , async ( ) => {
193237 scenario = [
@@ -256,7 +300,7 @@ describe("ClaudeSessionManager run_in_background drain (completed with pending b
256300 ( m as { terminal_reason ?: string } ) . terminal_reason === "completed" ,
257301 ) . length ;
258302
259- test ( "defers `completed` while a bg task is pending, resumes on task_notification, ends once" , async ( ) => {
303+ test ( "defers `completed` while a bg task is pending, replays it on task_notification, ends once" , async ( ) => {
260304 scenario = [
261305 assistant ( "dispatching" ) ,
262306 taskStarted ( "bg1" ) ,
@@ -274,22 +318,23 @@ describe("ClaudeSessionManager run_in_background drain (completed with pending b
274318
275319 // One terminal end — the intermediate `completed` must not fire it.
276320 expect ( spy . ends ) . toBe ( 1 ) ;
277- // Only the FINAL `completed` reaches the pipeline (one result per turn).
321+ // Only one `completed` reaches the pipeline (one result per turn).
278322 expect ( completedResultCount ( spy ) ) . toBe ( 1 ) ;
279- // Continuation flows through: notification + the post-resume assistant .
323+ // Notification flows through before the deferred terminal result is replayed .
280324 const subtypes = spy . passthroughs . map (
281325 ( m ) => ( m as { subtype ?: string } ) . subtype ,
282326 ) ;
283- expect ( subtypes ) . toContain ( "task_notification" ) ;
284- const assistantTexts = spy . passthroughs
285- . filter ( ( m ) => ( m as { type ?: string } ) . type === "assistant" )
286- . map (
287- ( m ) =>
288- ( m as { message ?: { content ?: { text ?: string } [ ] } } ) . message
289- ?. content ?. [ 0 ] ?. text ,
290- ) ;
291- expect ( assistantTexts ) . toContain ( "synthesizing" ) ;
292- // Usage recorded at the deferred pause AND the terminal result.
327+ const notificationIndex = subtypes . findIndex (
328+ ( subtype ) => subtype === "task_notification" ,
329+ ) ;
330+ const replayedCompletedIndex = spy . passthroughs . findIndex (
331+ ( m ) =>
332+ ( m as { type ?: string } ) . type === "result" &&
333+ ( m as { terminal_reason ?: string } ) . terminal_reason === "completed" ,
334+ ) ;
335+ expect ( notificationIndex ) . toBeGreaterThanOrEqual ( 0 ) ;
336+ expect ( replayedCompletedIndex ) . toBe ( notificationIndex + 1 ) ;
337+ // Usage recorded at the deferred pause and when replayed as terminal.
293338 expect ( spy . contextUsageUpdates ) . toBeGreaterThanOrEqual ( 2 ) ;
294339 } ) ;
295340
@@ -378,4 +423,99 @@ describe("ClaudeSessionManager run_in_background drain (completed with pending b
378423 . filter ( Boolean ) ;
379424 expect ( reasons ) . toContain ( "max_turns" ) ; // passed through, not deferred
380425 } ) ;
426+
427+ test ( "deferred completed closes immediately when task_notification drains the last pending task even if the iterator hangs" , async ( ) => {
428+ process . env . HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS = LONG_BG_DRAIN_TIMEOUT_MS ;
429+ hangAfterScenario = true ;
430+ scenario = [
431+ assistant ( "dispatching" ) ,
432+ taskStarted ( "bg1" ) ,
433+ result ( "completed" ) ,
434+ taskNotification ( "bg1" ) ,
435+ ] ;
436+ const spy = makeSpyEmitter ( ) ;
437+ await expectSettles (
438+ new ClaudeSessionManager ( ) . sendMessage (
439+ "req-bg-notify-close" ,
440+ baseParams ( ) ,
441+ spy . emitter ,
442+ ) ,
443+ "task_notification drain" ,
444+ ) ;
445+
446+ expect ( spy . ends ) . toBe ( 1 ) ;
447+ expect ( queryCloseCount ) . toBe ( 1 ) ;
448+ expect ( completedResultCount ( spy ) ) . toBe ( 1 ) ;
449+ expect (
450+ spy . passthroughs . some (
451+ ( m ) => ( m as { subtype ?: string } ) . subtype === "task_notification" ,
452+ ) ,
453+ ) . toBe ( true ) ;
454+ } ) ;
455+
456+ test ( "deferred completed closes when killed task_updated drains the last pending task without notification" , async ( ) => {
457+ process . env . HELMOR_CLAUDE_BG_DRAIN_TIMEOUT_MS = LONG_BG_DRAIN_TIMEOUT_MS ;
458+ hangAfterScenario = true ;
459+ scenario = [
460+ assistant ( "dispatching" ) ,
461+ taskStarted ( "bg1" ) ,
462+ result ( "completed" ) ,
463+ taskUpdated ( "bg1" , "killed" ) ,
464+ ] ;
465+ const spy = makeSpyEmitter ( ) ;
466+ await expectSettles (
467+ new ClaudeSessionManager ( ) . sendMessage (
468+ "req-bg-killed-close" ,
469+ baseParams ( ) ,
470+ spy . emitter ,
471+ ) ,
472+ "killed task_updated drain" ,
473+ ) ;
474+
475+ expect ( spy . ends ) . toBe ( 1 ) ;
476+ expect ( queryCloseCount ) . toBe ( 1 ) ;
477+ expect ( completedResultCount ( spy ) ) . toBe ( 1 ) ;
478+ expect (
479+ spy . passthroughs . some (
480+ ( m ) => ( m as { subtype ?: string } ) . subtype === "task_updated" ,
481+ ) ,
482+ ) . toBe ( true ) ;
483+ } ) ;
484+
485+ test ( "deferred completed replay is idempotent when another completed result arrives before finalization" , async ( ) => {
486+ const firstCompleted = {
487+ ...result ( "completed" ) ,
488+ uuid : "r-completed-first-0-0" ,
489+ } as SDKMessage ;
490+ const secondCompleted = {
491+ ...result ( "completed" ) ,
492+ uuid : "r-completed-second-0-0" ,
493+ } as SDKMessage ;
494+ scenario = [
495+ assistant ( "dispatching" ) ,
496+ taskStarted ( "bg1" ) ,
497+ firstCompleted ,
498+ secondCompleted ,
499+ taskNotification ( "bg1" ) ,
500+ ] ;
501+ const spy = makeSpyEmitter ( ) ;
502+ await new ClaudeSessionManager ( ) . sendMessage (
503+ "req-bg-idempotent" ,
504+ baseParams ( ) ,
505+ spy . emitter ,
506+ ) ;
507+
508+ expect ( spy . ends ) . toBe ( 1 ) ;
509+ expect ( queryCloseCount ) . toBe ( 1 ) ;
510+ const completedResults = spy . passthroughs . filter (
511+ ( m ) =>
512+ ( m as { type ?: string } ) . type === "result" &&
513+ ( m as { terminal_reason ?: string } ) . terminal_reason === "completed" ,
514+ ) ;
515+ expect ( completedResults ) . toHaveLength ( 1 ) ;
516+ expect ( ( completedResults [ 0 ] as { uuid ?: string } ) . uuid ) . toBe (
517+ "r-completed-second-0-0" ,
518+ ) ;
519+ expect ( spy . contextUsageUpdates ) . toBeGreaterThanOrEqual ( 3 ) ;
520+ } ) ;
381521} ) ;
0 commit comments