@@ -358,13 +358,19 @@ describe("CL-6943 reusable worker sessions", () => {
358358 expect ( store . resumeOne ( session . id ) ) . toEqual ( { ok : false , status : "shutdown" } ) ;
359359 } ) ;
360360
361- // CL-7001: a retained, still-open session used to be exempt from this cap
362- // entirely — no separate cap or TTL — which is exactly why every
363- // spawn_agent worker leaked by default. maxCompleted is now the one bound
364- // the store owns for every finished session, retained or not, and
365- // eviction releases the session's close handle instead of abandoning it.
366- test ( "pruneCompleted evicts a retained, still-open session past maxCompleted and releases it" , ( ) => {
367- const store = createSubAgentSessionStore ( { maxCompleted : 1 } ) ;
361+ // CL-7001 originally folded a retained, still-open session into
362+ // maxCompleted (the TUI display cap) with no separate bound at all,
363+ // fixing the unbounded leak but creating a new bug: resume_agent /
364+ // followup_task fail once more than `maxCompleted` (default 20) workers
365+ // have spawned, even though every one of them is still perfectly
366+ // reusable. CL-7007 gives open retained sessions their own cap
367+ // (`maxRetained`) instead — this test changed from asserting that
368+ // `maxCompleted` evicts a retained session (no longer true: retained
369+ // sessions are excluded from that cap, see isOpenRetained) to asserting
370+ // that `maxRetained` does, with the same "handles still get released"
371+ // guarantee.
372+ test ( "pruneRetained evicts a retained, still-open session past maxRetained and releases it" , ( ) => {
373+ const store = createSubAgentSessionStore ( { maxCompleted : 1 , maxRetained : 1 } ) ;
368374 const retained = store . start ( {
369375 description : "keep-me" ,
370376 agentId : "a" ,
@@ -378,14 +384,114 @@ describe("CL-6943 reusable worker sessions", () => {
378384 store . complete ( retained . id , "## Summary\nDone." ) ;
379385
380386 for ( let i = 0 ; i < 3 ; i ++ ) {
381- const s = store . start ( { description : `fill-${ i } ` , agentId : "a" , brief : "b" } ) ;
387+ const s = store . start ( {
388+ description : `fill-${ i } ` ,
389+ agentId : "a" ,
390+ brief : "b" ,
391+ retained : true ,
392+ } ) ;
393+ store . registerClose ( s . id , async ( ) => { } ) ;
382394 store . complete ( s . id , "## Summary\nDone." ) ;
383395 }
384396
385397 expect ( store . get ( retained . id ) ) . toBeUndefined ( ) ;
386398 expect ( closed ) . toBe ( true ) ;
387399 } ) ;
388400
401+ // CL-7002's fix (retained sessions are no longer exempt from any cap) must
402+ // survive CL-7007: a non-retained finished session still obeys
403+ // maxCompleted exactly as before.
404+ test ( "maxCompleted still evicts an ordinary (non-retained) finished session" , ( ) => {
405+ const store = createSubAgentSessionStore ( { maxCompleted : 1 } ) ;
406+ const first = store . start ( { description : "first" , agentId : "a" , brief : "b" } ) ;
407+ store . complete ( first . id , "## Summary\nDone." ) ;
408+
409+ for ( let i = 0 ; i < 3 ; i ++ ) {
410+ const s = store . start ( { description : `fill-${ i } ` , agentId : "a" , brief : "b" } ) ;
411+ store . complete ( s . id , "## Summary\nDone." ) ;
412+ }
413+
414+ expect ( store . get ( first . id ) ) . toBeUndefined ( ) ;
415+ } ) ;
416+
417+ test ( "resume_agent on a retention-evicted session returns an actionable status, not not_found" , ( ) => {
418+ const store = createSubAgentSessionStore ( { maxRetained : 1 } ) ;
419+ const retained = store . start ( {
420+ description : "keep-me" ,
421+ agentId : "a" ,
422+ brief : "b" ,
423+ retained : true ,
424+ } ) ;
425+ store . registerClose ( retained . id , async ( ) => { } ) ;
426+ store . complete ( retained . id , "## Summary\nDone." ) ;
427+
428+ for ( let i = 0 ; i < 3 ; i ++ ) {
429+ const s = store . start ( {
430+ description : `fill-${ i } ` ,
431+ agentId : "a" ,
432+ brief : "b" ,
433+ retained : true ,
434+ } ) ;
435+ store . registerClose ( s . id , async ( ) => { } ) ;
436+ store . complete ( s . id , "## Summary\nDone." ) ;
437+ }
438+
439+ const outcome = store . resumeOne ( retained . id ) ;
440+ expect ( outcome . ok ) . toBe ( false ) ;
441+ if ( ! outcome . ok ) {
442+ expect ( outcome . status ) . toBe ( "completed" ) ;
443+ expect ( outcome . hint ) . toMatch ( / r e a d _ a g e n t _ t r a c e / ) ;
444+ }
445+ } ) ;
446+
447+ test ( "a running session is never evicted by maxRetained even when the cap is exceeded" , ( ) => {
448+ const store = createSubAgentSessionStore ( { maxRetained : 1 } ) ;
449+ const running = store . start ( {
450+ description : "keep-me" ,
451+ agentId : "a" ,
452+ brief : "b" ,
453+ retained : true ,
454+ } ) ;
455+ store . markRunning ( running . id ) ;
456+ // Resume it back to "running" so it is an open, actively-driven session.
457+ store . registerClose ( running . id , async ( ) => { } ) ;
458+ store . complete ( running . id , "## Summary\nDone." ) ;
459+ store . resumeOne ( running . id ) ;
460+ expect ( store . get ( running . id ) ?. lifecycleStatus ) . toBe ( "running" ) ;
461+
462+ for ( let i = 0 ; i < 5 ; i ++ ) {
463+ const s = store . start ( {
464+ description : `fill-${ i } ` ,
465+ agentId : "a" ,
466+ brief : "b" ,
467+ retained : true ,
468+ } ) ;
469+ store . registerClose ( s . id , async ( ) => { } ) ;
470+ store . complete ( s . id , "## Summary\nDone." ) ;
471+ }
472+
473+ expect ( store . get ( running . id ) ) . toBeDefined ( ) ;
474+ expect ( store . get ( running . id ) ?. lifecycleStatus ) . toBe ( "running" ) ;
475+ } ) ;
476+
477+ test ( "maxRetained bounds memory: many spawned-and-completed retained sessions do not grow without limit" , ( ) => {
478+ const store = createSubAgentSessionStore ( { maxRetained : 5 } ) ;
479+ for ( let i = 0 ; i < 50 ; i ++ ) {
480+ const s = store . start ( {
481+ description : `worker-${ i } ` ,
482+ agentId : "a" ,
483+ brief : "b" ,
484+ retained : true ,
485+ } ) ;
486+ store . registerClose ( s . id , async ( ) => { } ) ;
487+ store . complete ( s . id , "## Summary\nDone." ) ;
488+ }
489+ const openRetained = store
490+ . list ( )
491+ . filter ( ( s ) => s . retained === true && s . lifecycleStatus === "completed" ) ;
492+ expect ( openRetained . length ) . toBeLessThanOrEqual ( 5 ) ;
493+ } ) ;
494+
389495 test ( "once closed, a retained session becomes a normal finished record subject to the cap" , async ( ) => {
390496 const store = createSubAgentSessionStore ( { maxCompleted : 1 } ) ;
391497 const retained = store . start ( {
0 commit comments