@@ -6,6 +6,7 @@ import { join } from "node:path";
66import { promisify } from "node:util" ;
77
88import { createFleetMailbox , createSpawnAgentTool } from "./agent-fleet.js" ;
9+ import { isLiveWaitStatus , projectWaitStatus } from "./lifecycle.js" ;
910import { unlimitedAdmissionQueue } from "./admission.js" ;
1011import { createSubAgentSessionStore } from "./session-store.js" ;
1112import { createPermissionGate } from "../permission/gate.js" ;
@@ -454,4 +455,80 @@ describe("spawn_agent worktree isolation", () => {
454455
455456 expect ( await pathExists ( completedWorkerCwd ) ) . toBe ( false ) ;
456457 } ) ;
458+
459+ test ( "interrupt during worktree setup settles the run instead of stranding it" , async ( ) => {
460+ const repo = await makeRepo ( ) ;
461+ tempDirs . push ( repo ) ;
462+ const workdirBase = await mkdtemp ( join ( tmpdir ( ) , "corbits-workdir-" ) ) ;
463+ tempDirs . push ( workdirBase ) ;
464+
465+ let started = 0 ;
466+ const { telemetry, events } = telemetryCapture ( ) ;
467+ const sessions = createSubAgentSessionStore ( ) ;
468+ const mailbox = createFleetMailbox ( sessions ) ;
469+ const tool = createSpawnAgentTool ( {
470+ permissionGate : testPermissionGate ,
471+ cwd : repo ,
472+ getWorkdirBase : ( ) => workdirBase ,
473+ provider,
474+ useWorktree : true ,
475+ telemetry,
476+ run : async ( ) => {
477+ started += 1 ;
478+ return { report : "ok" } ;
479+ } ,
480+ sessions,
481+ fleetRecords : mailbox ,
482+ admission : unlimitedAdmissionQueue ( ) ,
483+ } ) ;
484+ if ( tool . kind !== "full" ) throw new Error ( "expected full tool" ) ;
485+ const spawned = await tool . handler (
486+ {
487+ id : "wt-interrupt" ,
488+ name : "spawn_agent" ,
489+ arguments : {
490+ description : "interrupted setup" ,
491+ prompt : "Do the work" ,
492+ intent : "explore" ,
493+ } ,
494+ } ,
495+ new AbortController ( ) . signal ,
496+ ) ;
497+ const content = typeof spawned . content === "string" ? spawned . content : "" ;
498+ const agentId = ( JSON . parse ( content ) as { agent_id : string } ) . agent_id ;
499+
500+ // CL-7787: the fleet admitted the spawn and marked a run in flight, then
501+ // suspended on worktree creation — the interrupt lands in exactly that
502+ // window, before any run handle exists.
503+ expect ( sessions . isRunInFlight ( agentId ) ) . toBe ( true ) ;
504+ expect ( sessions . interruptOne ( agentId ) . ok ) . toBe ( true ) ;
505+
506+ // Once the worktree resolves, the stranded run must settle through the
507+ // normal terminal path: wait status leaves "running", the fleet goes dry
508+ // so mail drives fire, and run() never starts leftover work.
509+ await waitFor (
510+ ( ) =>
511+ mailbox . peek ( agentId ) !== undefined &&
512+ ! isLiveWaitStatus ( defined ( mailbox . peek ( agentId ) ) . status ) ,
513+ ) ;
514+ expect ( started ) . toBe ( 0 ) ;
515+ expect ( sessions . isRunInFlight ( agentId ) ) . toBe ( false ) ;
516+ const snap = defined ( sessions . get ( agentId ) ) ;
517+ expect ( snap . lifecycleStatus ) . toBe ( "interrupted" ) ;
518+ expect (
519+ projectWaitStatus ( snap . lifecycle , sessions . isRunInFlight ( agentId ) ) ,
520+ ) . toBe ( "interrupted" ) ;
521+ expect ( mailbox . peek ( agentId ) ?. status ) . toBe ( "interrupted" ) ;
522+ // The fleet is dry: no session projects a live wait status, so mail
523+ // drives fire.
524+ expect (
525+ sessions
526+ . list ( )
527+ . every ( ( s ) => ! isLiveWaitStatus ( projectWaitStatus ( s . lifecycle , s . runInFlight === true ) ) ) ,
528+ ) . toBe ( true ) ;
529+ await waitFor ( ( ) => events . some ( ( event ) => event . event === "subagent_end" ) ) ;
530+ const ends = events . filter ( ( event ) => event . event === "subagent_end" ) ;
531+ expect ( ends ) . toHaveLength ( 1 ) ;
532+ expect ( ends [ 0 ] ?. properties ) . toMatchObject ( { status : "interrupted" } ) ;
533+ } ) ;
457534} ) ;
0 commit comments