@@ -396,6 +396,114 @@ describe("runPrePrChecksWithFixes", () => {
396396 expect ( checkRuns ) . toBe ( 1 ) ;
397397 } ) ;
398398
399+ it ( "launches the repair wrapper detached and reads its sentinel instead of holding the command open" , async ( ) => {
400+ // A blocking launch keeps one sandbox ndjson stream open for the whole
401+ // repair agent. Production runs whose pre-PR checks crossed a function
402+ // invocation boundary lost that stream mid-flight, and the SDK's parse
403+ // error was reported as a launch that never produced a process: empty
404+ // output, empty logs, no exit code, for an agent that was in fact running.
405+ // The launch has to return before the agent finishes, and completion has to
406+ // come from the wrapper's sentinel file.
407+ vi . useFakeTimers ( ) ;
408+ try {
409+ let checkRuns = 0 ;
410+ let sentinelReads = 0 ;
411+ let sentinelReadsWhenLaunchReturned = - 1 ;
412+ mockRunCommand . mockImplementation ( ( cmd , args ) => {
413+ const artifact = phaseArtifactCommand ( cmd , args , "codex" ) ;
414+ if ( artifact ) return artifact ;
415+ if ( isWrapperLaunch ( cmd ) ) {
416+ sentinelReadsWhenLaunchReturned = sentinelReads ;
417+ // A detached command has not exited when runCommand resolves.
418+ return detachedCommand ( ) ;
419+ }
420+ if ( isSentinelRead ( cmd , args ) ) {
421+ sentinelReads ++ ;
422+ return commandResult ( sentinelReads >= 2 ? 0 : 1 ) ;
423+ }
424+ if ( cmd === "cat" && args [ 0 ] === WORKSPACE_MANIFEST_PATH ) {
425+ return commandResult ( 0 , JSON . stringify ( manifest ) ) ;
426+ }
427+ if ( cmd === "git" && args [ 0 ] === "-C" && args [ 2 ] === "rev-parse" ) {
428+ return commandResult ( 0 , "web-head" ) ;
429+ }
430+ if ( isConfiguredCheck ( cmd ) ) {
431+ checkRuns ++ ;
432+ return checkRuns === 1
433+ ? commandResult ( 1 , "" , "Type error" )
434+ : commandResult ( 0 , "ok" ) ;
435+ }
436+ return commandResult ( 0 , "" ) ;
437+ } ) ;
438+
439+ const pending = runPrePrChecksWithFixes (
440+ "sbx-test-123" ,
441+ { repositories : [ config . repositories [ 0 ] ! ] } ,
442+ "codex" ,
443+ "gpt-5" ,
444+ ) ;
445+ const result = await drainPollTicks ( pending ) ;
446+
447+ expect ( result . passed ) . toBe ( true ) ;
448+ expect ( result . fixCycles ) . toBe ( 1 ) ;
449+ expect ( result . agentFailure ) . toBeUndefined ( ) ;
450+ expect ( mockRunCommand ) . toHaveBeenCalledWith (
451+ expect . objectContaining ( {
452+ cmd : "bash" ,
453+ args : [ "/tmp/pre-pr-fix-1-wrapper.sh" ] ,
454+ cwd : "/vercel/sandbox" ,
455+ detached : true ,
456+ } ) ,
457+ ) ;
458+ expect ( mockRunCommand ) . toHaveBeenCalledWith ( "test" , [
459+ "-f" ,
460+ "/tmp/pre-pr-fix-1-done" ,
461+ ] ) ;
462+ // The launch resolved before any sentinel existed, and the phase only
463+ // ended once a later poll found one: completion is driven by the poll,
464+ // not by the launch call.
465+ expect ( sentinelReadsWhenLaunchReturned ) . toBe ( 0 ) ;
466+ expect ( sentinelReads ) . toBe ( 2 ) ;
467+ } finally {
468+ vi . useRealTimers ( ) ;
469+ }
470+ } ) ;
471+
472+ it ( "propagates a deadline abort raised while the repair phase is polled" , async ( ) => {
473+ // The deadline that used to abort the blocking launch now expires during
474+ // the poll, and it must stay a real TimeoutError rather than become a
475+ // "could not be launched" failure attributed to the provider.
476+ let sentinelReads = 0 ;
477+ mockRunCommand . mockImplementation ( ( cmd , args ) => {
478+ const artifact = phaseArtifactCommand ( cmd , args , "codex" ) ;
479+ if ( artifact ) return artifact ;
480+ if ( isWrapperLaunch ( cmd ) ) return detachedCommand ( ) ;
481+ if ( isSentinelRead ( cmd , args ) ) {
482+ sentinelReads ++ ;
483+ return commandResult ( 1 ) ;
484+ }
485+ if ( cmd === "cat" && args [ 0 ] === WORKSPACE_MANIFEST_PATH ) {
486+ return commandResult ( 0 , JSON . stringify ( manifest ) ) ;
487+ }
488+ if ( isHeadInspection ( cmd ) ) return commandResult ( 0 , "web-head" ) ;
489+ if ( isConfiguredCheck ( cmd ) ) return commandResult ( 1 , "" , "still failing" ) ;
490+ return commandResult ( 0 , "" ) ;
491+ } ) ;
492+
493+ await expect (
494+ runPrePrChecksWithFixes (
495+ "sbx-test-123" ,
496+ { repositories : [ config . repositories [ 0 ] ! ] } ,
497+ "codex" ,
498+ "gpt-5" ,
499+ 3 ,
500+ 50 ,
501+ ) ,
502+ ) . rejects . toMatchObject ( { name : "TimeoutError" } ) ;
503+
504+ expect ( sentinelReads ) . toBeGreaterThan ( 0 ) ;
505+ } ) ;
506+
399507 it ( "names the cause when the repair process cannot be launched at all" , async ( ) => {
400508 // Production runs died here with a failure that named only the boundary:
401509 // no exit code, no captured bytes, and the thrown error destroyed at the
@@ -744,6 +852,40 @@ function isConfiguredCheck(cmd: unknown): boolean {
744852 ) ;
745853}
746854
855+ /** What a detached `runCommand` resolves to: the process is still running, so
856+ * there is no exit code yet. */
857+ function detachedCommand ( ) {
858+ return {
859+ exitCode : null ,
860+ stdout : vi . fn ( ) . mockResolvedValue ( "" ) ,
861+ stderr : vi . fn ( ) . mockResolvedValue ( "" ) ,
862+ } ;
863+ }
864+
865+ function isSentinelRead ( cmd : unknown , args : unknown ) : boolean {
866+ return cmd === "test" && Array . isArray ( args ) && args [ 0 ] === "-f" ;
867+ }
868+
869+ /** Run the fake clock forward until the polled run settles, so a poll tick
870+ * costs the suite nothing. */
871+ async function drainPollTicks < T > ( pending : Promise < T > ) : Promise < T > {
872+ let settled = false ;
873+ const watched = pending . then (
874+ ( value ) => {
875+ settled = true ;
876+ return value ;
877+ } ,
878+ ( error ) => {
879+ settled = true ;
880+ throw error ;
881+ } ,
882+ ) ;
883+ for ( let tick = 0 ; tick < 20 && ! settled ; tick ++ ) {
884+ await vi . advanceTimersByTimeAsync ( 5_000 ) ;
885+ }
886+ return watched ;
887+ }
888+
747889function isWrapperLaunch ( cmd : unknown ) : boolean {
748890 const objectCommand = cmd as { cmd ?: unknown ; args ?: unknown } ;
749891 return (
0 commit comments