@@ -5396,6 +5396,11 @@ export async function runTestWaitMany(
53965396 | { kind : 'error' ; code : string ; exitCode : number } ;
53975397
53985398 const pollOne = async ( runId : string ) : Promise < WaitOutcome > => {
5399+ // A member dequeued AFTER the shared deadline has passed must not be
5400+ // granted a fresh minimum poll window (with --max-concurrency 1 that
5401+ // would extend the invocation by ~1s per queued run past --timeout).
5402+ const remainingSeconds = Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ;
5403+ if ( remainingSeconds <= 0 ) return { kind : 'timeout' } ;
53995404 const resolveAlternate = makeBackendWaitFallback ( {
54005405 client,
54015406 resolveTestId : run => run . testId ,
@@ -5404,7 +5409,7 @@ export async function runTestWaitMany(
54045409 } ) ;
54055410 try {
54065411 const run = await pollRunUntilTerminal ( client , runId , {
5407- timeoutSeconds : Math . max ( 1 , Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ) ,
5412+ timeoutSeconds : remainingSeconds ,
54085413 sleep : deps . sleep ,
54095414 onTransition : opts . verbose ? ( msg : string ) => stderrFn ( `[verbose] ${ msg } ` ) : undefined ,
54105415 onTick : ( run , elapsedMs ) => {
@@ -5448,14 +5453,27 @@ export async function runTestWaitMany(
54485453 } catch ( fanOutErr ) {
54495454 if ( fanOutErr instanceof RequestTimeoutError ) {
54505455 // Same contract as the batch pollers: leave stdout parseable before
5451- // exiting 7 — every dispatched id is re-attachable in one command.
5456+ // exiting 7. Members that already settled keep their real status; only
5457+ // the still-unfinished ids are marked running and named in the hint
5458+ // (re-attaching to an already-terminal run would be a wasted command).
54525459 ticker . finalize ( 'Multi-run wait — request timed out' ) ;
54535460 const partial = {
5454- results : opts . runIds . map ( runId => ( { runId, status : 'running' } ) ) ,
5461+ results : opts . runIds . map ( ( runId ) : CliMultiWaitResult => {
5462+ const outcome = outcomes . get ( runId ) ;
5463+ if ( outcome === undefined ) return { runId, status : 'running' } ;
5464+ if ( outcome . kind === 'timeout' ) return { runId, status : 'timeout' } ;
5465+ if ( outcome . kind === 'error' ) return { runId, status : `error:${ outcome . code } ` } ;
5466+ return { runId, status : outcome . run . status , testId : outcome . run . testId } ;
5467+ } ) ,
54555468 summary : { total : opts . runIds . length } ,
54565469 } ;
5457- out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } running` ) . join ( '\n' ) ) ;
5458- stderrFn ( `Re-attach with: testsprite test wait ${ opts . runIds . join ( ' ' ) } ` ) ;
5470+ out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } ${ r . status } ` ) . join ( '\n' ) ) ;
5471+ const unfinished = partial . results
5472+ . filter ( r => r . status === 'running' || r . status === 'timeout' )
5473+ . map ( r => r . runId ) ;
5474+ if ( unfinished . length > 0 ) {
5475+ stderrFn ( `Re-attach with: testsprite test wait ${ unfinished . join ( ' ' ) } ` ) ;
5476+ }
54595477 }
54605478 throw fanOutErr ;
54615479 }
@@ -5483,9 +5501,14 @@ export async function runTestWaitMany(
54835501 ] . join ( '\n' ) ,
54845502 ) ;
54855503
5486- const timedOutIds = results . filter ( r => r . status === 'timeout' ) . map ( r => r . runId ) ;
5487- if ( timedOutIds . length > 0 ) {
5488- stderrFn ( `Re-attach with: testsprite test wait ${ timedOutIds . join ( ' ' ) } ` ) ;
5504+ // Every member that did not reach a terminal verdict is re-attachable:
5505+ // timeouts (still running server-side) and poll errors (e.g. a transient
5506+ // transport failure) both belong in the hint; terminal runs do not.
5507+ const unfinishedIds = results
5508+ . filter ( r => r . status === 'timeout' || r . status . startsWith ( 'error:' ) )
5509+ . map ( r => r . runId ) ;
5510+ if ( unfinishedIds . length > 0 ) {
5511+ stderrFn ( `Re-attach with: testsprite test wait ${ unfinishedIds . join ( ' ' ) } ` ) ;
54895512 }
54905513
54915514 // Worst-status exit: auth escalates (a rejected key fails every member the
@@ -8333,8 +8356,9 @@ export function createTestCommand(deps: TestDeps = {}): Command {
83338356 ' 0 passed\n' +
83348357 ' 1 failed / blocked / cancelled\n' +
83358358 ' 3 auth error\n' +
8336- ' 4 run not found\n' +
8337- ' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
8359+ ' 4 run not found (single run-id; with several ids a per-member poll error\n' +
8360+ ' is recorded as error:<CODE> in its row and folded into exit 7)\n' +
8361+ ' 7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>\n' +
83388362 ' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
83398363 '\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>' ,
83408364 )
0 commit comments