@@ -5250,6 +5250,11 @@ export async function runTestWaitMany(
52505250 | { kind : 'error' ; code : string ; exitCode : number } ;
52515251
52525252 const pollOne = async ( runId : string ) : Promise < WaitOutcome > => {
5253+ // A member dequeued AFTER the shared deadline has passed must not be
5254+ // granted a fresh minimum poll window (with --max-concurrency 1 that
5255+ // would extend the invocation by ~1s per queued run past --timeout).
5256+ const remainingSeconds = Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ;
5257+ if ( remainingSeconds <= 0 ) return { kind : 'timeout' } ;
52535258 const resolveAlternate = makeBackendWaitFallback ( {
52545259 client,
52555260 resolveTestId : run => run . testId ,
@@ -5258,7 +5263,7 @@ export async function runTestWaitMany(
52585263 } ) ;
52595264 try {
52605265 const run = await pollRunUntilTerminal ( client , runId , {
5261- timeoutSeconds : Math . max ( 1 , Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ) ,
5266+ timeoutSeconds : remainingSeconds ,
52625267 sleep : deps . sleep ,
52635268 onTransition : opts . verbose ? ( msg : string ) => stderrFn ( `[verbose] ${ msg } ` ) : undefined ,
52645269 onTick : ( run , elapsedMs ) => {
@@ -5302,14 +5307,27 @@ export async function runTestWaitMany(
53025307 } catch ( fanOutErr ) {
53035308 if ( fanOutErr instanceof RequestTimeoutError ) {
53045309 // Same contract as the batch pollers: leave stdout parseable before
5305- // exiting 7 — every dispatched id is re-attachable in one command.
5310+ // exiting 7. Members that already settled keep their real status; only
5311+ // the still-unfinished ids are marked running and named in the hint
5312+ // (re-attaching to an already-terminal run would be a wasted command).
53065313 ticker . finalize ( 'Multi-run wait — request timed out' ) ;
53075314 const partial = {
5308- results : opts . runIds . map ( runId => ( { runId, status : 'running' } ) ) ,
5315+ results : opts . runIds . map ( ( runId ) : CliMultiWaitResult => {
5316+ const outcome = outcomes . get ( runId ) ;
5317+ if ( outcome === undefined ) return { runId, status : 'running' } ;
5318+ if ( outcome . kind === 'timeout' ) return { runId, status : 'timeout' } ;
5319+ if ( outcome . kind === 'error' ) return { runId, status : `error:${ outcome . code } ` } ;
5320+ return { runId, status : outcome . run . status , testId : outcome . run . testId } ;
5321+ } ) ,
53095322 summary : { total : opts . runIds . length } ,
53105323 } ;
5311- out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } running` ) . join ( '\n' ) ) ;
5312- stderrFn ( `Re-attach with: testsprite test wait ${ opts . runIds . join ( ' ' ) } ` ) ;
5324+ out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } ${ r . status } ` ) . join ( '\n' ) ) ;
5325+ const unfinished = partial . results
5326+ . filter ( r => r . status === 'running' || r . status === 'timeout' )
5327+ . map ( r => r . runId ) ;
5328+ if ( unfinished . length > 0 ) {
5329+ stderrFn ( `Re-attach with: testsprite test wait ${ unfinished . join ( ' ' ) } ` ) ;
5330+ }
53135331 }
53145332 throw fanOutErr ;
53155333 }
@@ -5337,9 +5355,14 @@ export async function runTestWaitMany(
53375355 ] . join ( '\n' ) ,
53385356 ) ;
53395357
5340- const timedOutIds = results . filter ( r => r . status === 'timeout' ) . map ( r => r . runId ) ;
5341- if ( timedOutIds . length > 0 ) {
5342- stderrFn ( `Re-attach with: testsprite test wait ${ timedOutIds . join ( ' ' ) } ` ) ;
5358+ // Every member that did not reach a terminal verdict is re-attachable:
5359+ // timeouts (still running server-side) and poll errors (e.g. a transient
5360+ // transport failure) both belong in the hint; terminal runs do not.
5361+ const unfinishedIds = results
5362+ . filter ( r => r . status === 'timeout' || r . status . startsWith ( 'error:' ) )
5363+ . map ( r => r . runId ) ;
5364+ if ( unfinishedIds . length > 0 ) {
5365+ stderrFn ( `Re-attach with: testsprite test wait ${ unfinishedIds . join ( ' ' ) } ` ) ;
53435366 }
53445367
53455368 // Worst-status exit: auth escalates (a rejected key fails every member the
@@ -8156,8 +8179,9 @@ export function createTestCommand(deps: TestDeps = {}): Command {
81568179 ' 0 passed\n' +
81578180 ' 1 failed / blocked / cancelled\n' +
81588181 ' 3 auth error\n' +
8159- ' 4 run not found\n' +
8160- ' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
8182+ ' 4 run not found (single run-id; with several ids a per-member poll error\n' +
8183+ ' is recorded as error:<CODE> in its row and folded into exit 7)\n' +
8184+ ' 7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>\n' +
81618185 ' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
81628186 '\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>' ,
81638187 )
0 commit comments