@@ -1502,13 +1502,16 @@ describe("interrupt_agent unblocks wait_agents", () => {
15021502
15031503 test ( "close overlay without in-flight wait stays interrupted after followup complete" , async ( ) => {
15041504 const gate = deferred < RunSubAgentResult > ( ) ;
1505- const followupGate = deferred < string > ( ) ;
15061505 const closeHold = deferred < undefined > ( ) ;
1506+ const followupCalls : string [ ] = [ ] ;
15071507 const deps = makeDeps ( async ( params ) => {
15081508 params . onAgentReady ?.( {
15091509 close : async ( ) => closeHold . promise ,
15101510 interrupt : ( ) => undefined ,
1511- followup : async ( ) => followupGate . promise ,
1511+ followup : async ( message : string ) => {
1512+ followupCalls . push ( message ) ;
1513+ return "should never run" ;
1514+ } ,
15121515 deliver : ( ) => undefined ,
15131516 } ) ;
15141517 return gate . promise ;
@@ -1552,25 +1555,16 @@ describe("interrupt_agent unblocks wait_agents", () => {
15521555 new AbortController ( ) . signal ,
15531556 ) ;
15541557
1555- followupGate . resolve ( "followup after close overlay" ) ;
1556- await new Promise < void > ( ( resolve ) => {
1557- const done = ( ) : boolean =>
1558- deps . sessions . get ( id ) ?. lifecycle . state === "completed" ;
1559- if ( done ( ) ) {
1560- resolve ( ) ;
1561- return ;
1562- }
1563- const unsub = deps . sessions . subscribe ( ( ) => {
1564- if ( done ( ) ) {
1565- unsub ( ) ;
1566- resolve ( ) ;
1567- }
1568- } ) ;
1569- if ( done ( ) ) {
1570- unsub ( ) ;
1571- resolve ( ) ;
1572- }
1573- } ) ;
1558+ closeHold . resolve ( undefined ) ;
1559+ await closing ;
1560+ // CL-7344: close drops the stashed follow-up — it never runs against
1561+ // the closed agent, and the close overlay survives the run settling.
1562+ expect ( followupCalls ) . toEqual ( [ ] ) ;
1563+ gate . resolve ( {
1564+ report : "original interrupted" ,
1565+ interrupted : true ,
1566+ } as RunSubAgentResult ) ;
1567+ await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
15741568
15751569 expect ( deps . fleetRecords . peek ( id ) ?. status ) . toBe ( "interrupted" ) ;
15761570 const listed = await callTool ( list , { } ) ;
@@ -1583,13 +1577,6 @@ describe("interrupt_agent unblocks wait_agents", () => {
15831577 expect ( waited . timed_out ) . toBe ( false ) ;
15841578 const results = waited . results as { status : string } [ ] ;
15851579 expect ( defined ( results [ 0 ] ) . status ) . toBe ( "interrupted" ) ;
1586-
1587- closeHold . resolve ( undefined ) ;
1588- await closing ;
1589- gate . resolve ( {
1590- report : "original interrupted" ,
1591- interrupted : true ,
1592- } as RunSubAgentResult ) ;
15931580 } ) ;
15941581
15951582 test ( "completeAfterInterrupt does not clear a close overlay" , ( ) => {
@@ -1642,14 +1629,17 @@ describe("interrupt_agent unblocks wait_agents", () => {
16421629 message : "stop that" ,
16431630 interrupt : true ,
16441631 } ) ;
1645- followupGate . reject ( new Error ( "followup failed" ) ) ;
1646- await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
1632+ // CL-7344: the follow-up is stashed until the original run settles; the
1633+ // salvage handoff launches it, and its rejection restores the
1634+ // interrupted lane so wait collects the salvage.
16471635 gate . resolve ( {
16481636 report :
16491637 "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n" ,
16501638 interrupted : true ,
16511639 } as RunSubAgentResult ) ;
16521640 await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
1641+ followupGate . reject ( new Error ( "followup failed" ) ) ;
1642+ await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
16531643
16541644 const waited = await callTool ( wait , { targets : [ id ] , timeout_ms : 5000 } ) ;
16551645 expect ( waited . timed_out ) . toBe ( false ) ;
@@ -1658,21 +1648,14 @@ describe("interrupt_agent unblocks wait_agents", () => {
16581648 expect ( defined ( results [ 0 ] ) . report ) . toContain ( "salvage" ) ;
16591649 } ) ;
16601650
1661- test ( "send_input interrupt queued overlay clears when the followup is admitted" , async ( ) => {
1662- const admission = createAdmissionQueue ( { capacity : 1 } ) ;
1663- const sessions = createSubAgentSessionStore ( { admission } ) ;
1651+ test ( "send_input interrupt on a live lane stashes the followup until the run settles" , async ( ) => {
1652+ const sessions = createSubAgentSessionStore ( ) ;
16641653 const fleetRecords = createFleetMailbox ( sessions ) ;
1665- admission . enqueue ( {
1666- id : "holder" ,
1667- provider : "p" ,
1668- start : ( ) => undefined ,
1669- } ) ;
16701654 const worker = sessions . start ( {
16711655 description : "looping" ,
16721656 agentId : "explorer" ,
16731657 brief : "b" ,
16741658 retained : true ,
1675- provider : "p" ,
16761659 } ) ;
16771660 sessions . markRunning ( worker . id ) ;
16781661 fleetRecords . register ( worker . id ) ;
@@ -1690,51 +1673,44 @@ describe("interrupt_agent unblocks wait_agents", () => {
16901673 interrupt : true ,
16911674 } ) ;
16921675 expect ( sent . status ) . toBe ( "interrupted" ) ;
1693- expect ( sessions . get ( worker . id ) ?. lifecycleStatus ) . toBe ( "pending_init" ) ;
1676+ // CL-7344: the interrupt stashes the follow-up instead of starting it;
1677+ // the lane is still the live run, so the session stays running while
1678+ // wait/list stay live until the run settles and hands off.
1679+ expect ( sessions . get ( worker . id ) ?. lifecycleStatus ) . toBe ( "running" ) ;
16941680
1695- const queuedWait = await callTool ( wait , {
1681+ const liveWait = await callTool ( wait , {
16961682 targets : [ worker . id ] ,
16971683 timeout_ms : 50 ,
16981684 } ) ;
1699- expect ( queuedWait . timed_out ) . toBe ( true ) ;
1700- expect (
1701- defined ( ( queuedWait . results as { status : string } [ ] ) [ 0 ] ) . status ,
1702- ) . toBe ( "queued" ) ;
1703- const queuedList = await callTool ( list , { } ) ;
1704- const queuedEntry = (
1705- queuedList . agents as {
1685+ expect ( liveWait . timed_out ) . toBe ( true ) ;
1686+ expect ( defined ( ( liveWait . results as { status : string } [ ] ) [ 0 ] ) . status ) . toBe (
1687+ "running" ,
1688+ ) ;
1689+ const liveList = await callTool ( list , { } ) ;
1690+ const liveEntry = (
1691+ liveList . agents as {
17061692 agent_id : string ;
17071693 status : string ;
17081694 lifecycle : string ;
17091695 } [ ]
17101696 ) . find ( ( a ) => a . agent_id === worker . id ) ;
1711- expect ( queuedEntry ?. status ) . toBe ( "queued" ) ;
1712- expect ( queuedEntry ?. lifecycle ) . toBe ( "pending_init" ) ;
1713-
1714- admission . release ( "holder" ) ;
1715- await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
1697+ expect ( liveEntry ?. status ) . toBe ( "running" ) ;
1698+ expect ( liveEntry ?. lifecycle ) . toBe ( "running" ) ;
17161699
1700+ // Settling the original run launches the stashed follow-up.
1701+ sessions . attachReport ( worker . id , "interrupted salvage" , {
1702+ stopReason : "interrupted" ,
1703+ } ) ;
17171704 expect ( sessions . get ( worker . id ) ?. lifecycleStatus ) . toBe ( "running" ) ;
1718- const runningWait = await callTool ( wait , {
1705+ followupGate . resolve ( "later" ) ;
1706+ const done = await callTool ( wait , {
17191707 targets : [ worker . id ] ,
1720- timeout_ms : 50 ,
1708+ timeout_ms : 5000 ,
17211709 } ) ;
1722- expect ( runningWait . timed_out ) . toBe ( true ) ;
1723- expect (
1724- defined ( ( runningWait . results as { status : string } [ ] ) [ 0 ] ) . status ,
1725- ) . toBe ( "running" ) ;
1726- const runningList = await callTool ( list , { } ) ;
1727- const runningEntry = (
1728- runningList . agents as {
1729- agent_id : string ;
1730- status : string ;
1731- lifecycle : string ;
1732- } [ ]
1733- ) . find ( ( a ) => a . agent_id === worker . id ) ;
1734- expect ( runningEntry ?. status ) . toBe ( "running" ) ;
1735- expect ( runningEntry ?. lifecycle ) . toBe ( "running" ) ;
1736-
1737- followupGate . resolve ( "later" ) ;
1710+ expect ( done . timed_out ) . toBe ( false ) ;
1711+ const results = done . results as { status : string ; report ?: string } [ ] ;
1712+ expect ( defined ( results [ 0 ] ) . status ) . toBe ( "done" ) ;
1713+ expect ( defined ( results [ 0 ] ) . report ) . toBe ( "later" ) ;
17381714 } ) ;
17391715
17401716 test ( "soft-interrupt wait path collects so omitted re-wait does not re-deliver" , async ( ) => {
@@ -1910,8 +1886,13 @@ describe("interrupt_agent unblocks wait_agents", () => {
19101886 message : "stop that" ,
19111887 interrupt : true ,
19121888 } ) ;
1889+ // CL-7344: the follow-up is stashed until the original run settles; the
1890+ // salvage handoff launches it, so the run must settle first.
1891+ gate . resolve ( {
1892+ report : "original interrupted" ,
1893+ interrupted : true ,
1894+ } as RunSubAgentResult ) ;
19131895 followupGate . resolve ( "followup report" ) ;
1914- await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
19151896 const waited = await callTool ( wait , { targets : [ id ] , timeout_ms : 5000 } ) ;
19161897 expect ( waited . timed_out ) . toBe ( false ) ;
19171898 const results = waited . results as { status : string ; report ?: string } [ ] ;
0 commit comments