@@ -4984,6 +4984,11 @@ export async function runTestWaitMany(
49844984 | { kind : 'error' ; code : string ; exitCode : number } ;
49854985
49864986 const pollOne = async ( runId : string ) : Promise < WaitOutcome > => {
4987+ // A member dequeued AFTER the shared deadline has passed must not be
4988+ // granted a fresh minimum poll window (with --max-concurrency 1 that
4989+ // would extend the invocation by ~1s per queued run past --timeout).
4990+ const remainingSeconds = Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ;
4991+ if ( remainingSeconds <= 0 ) return { kind : 'timeout' } ;
49874992 const resolveAlternate = makeBackendWaitFallback ( {
49884993 client,
49894994 resolveTestId : run => run . testId ,
@@ -4992,7 +4997,7 @@ export async function runTestWaitMany(
49924997 } ) ;
49934998 try {
49944999 const run = await pollRunUntilTerminal ( client , runId , {
4995- timeoutSeconds : Math . max ( 1 , Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ) ,
5000+ timeoutSeconds : remainingSeconds ,
49965001 sleep : deps . sleep ,
49975002 onTransition : opts . verbose ? ( msg : string ) => stderrFn ( `[verbose] ${ msg } ` ) : undefined ,
49985003 onTick : ( run , elapsedMs ) => {
@@ -5036,14 +5041,27 @@ export async function runTestWaitMany(
50365041 } catch ( fanOutErr ) {
50375042 if ( fanOutErr instanceof RequestTimeoutError ) {
50385043 // Same contract as the batch pollers: leave stdout parseable before
5039- // exiting 7 — every dispatched id is re-attachable in one command.
5044+ // exiting 7. Members that already settled keep their real status; only
5045+ // the still-unfinished ids are marked running and named in the hint
5046+ // (re-attaching to an already-terminal run would be a wasted command).
50405047 ticker . finalize ( 'Multi-run wait — request timed out' ) ;
50415048 const partial = {
5042- results : opts . runIds . map ( runId => ( { runId, status : 'running' } ) ) ,
5049+ results : opts . runIds . map ( ( runId ) : CliMultiWaitResult => {
5050+ const outcome = outcomes . get ( runId ) ;
5051+ if ( outcome === undefined ) return { runId, status : 'running' } ;
5052+ if ( outcome . kind === 'timeout' ) return { runId, status : 'timeout' } ;
5053+ if ( outcome . kind === 'error' ) return { runId, status : `error:${ outcome . code } ` } ;
5054+ return { runId, status : outcome . run . status , testId : outcome . run . testId } ;
5055+ } ) ,
50435056 summary : { total : opts . runIds . length } ,
50445057 } ;
5045- out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } running` ) . join ( '\n' ) ) ;
5046- stderrFn ( `Re-attach with: testsprite test wait ${ opts . runIds . join ( ' ' ) } ` ) ;
5058+ out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } ${ r . status } ` ) . join ( '\n' ) ) ;
5059+ const unfinished = partial . results
5060+ . filter ( r => r . status === 'running' || r . status === 'timeout' )
5061+ . map ( r => r . runId ) ;
5062+ if ( unfinished . length > 0 ) {
5063+ stderrFn ( `Re-attach with: testsprite test wait ${ unfinished . join ( ' ' ) } ` ) ;
5064+ }
50475065 }
50485066 throw fanOutErr ;
50495067 }
@@ -5071,9 +5089,14 @@ export async function runTestWaitMany(
50715089 ] . join ( '\n' ) ,
50725090 ) ;
50735091
5074- const timedOutIds = results . filter ( r => r . status === 'timeout' ) . map ( r => r . runId ) ;
5075- if ( timedOutIds . length > 0 ) {
5076- stderrFn ( `Re-attach with: testsprite test wait ${ timedOutIds . join ( ' ' ) } ` ) ;
5092+ // Every member that did not reach a terminal verdict is re-attachable:
5093+ // timeouts (still running server-side) and poll errors (e.g. a transient
5094+ // transport failure) both belong in the hint; terminal runs do not.
5095+ const unfinishedIds = results
5096+ . filter ( r => r . status === 'timeout' || r . status . startsWith ( 'error:' ) )
5097+ . map ( r => r . runId ) ;
5098+ if ( unfinishedIds . length > 0 ) {
5099+ stderrFn ( `Re-attach with: testsprite test wait ${ unfinishedIds . join ( ' ' ) } ` ) ;
50775100 }
50785101
50795102 // Worst-status exit: auth escalates (a rejected key fails every member the
@@ -7708,8 +7731,9 @@ export function createTestCommand(deps: TestDeps = {}): Command {
77087731 ' 0 passed\n' +
77097732 ' 1 failed / blocked / cancelled\n' +
77107733 ' 3 auth error\n' +
7711- ' 4 run not found\n' +
7712- ' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
7734+ ' 4 run not found (single run-id; with several ids a per-member poll error\n' +
7735+ ' is recorded as error:<CODE> in its row and folded into exit 7)\n' +
7736+ ' 7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>\n' +
77137737 ' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
77147738 '\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>' ,
77157739 )
0 commit comments