@@ -4919,6 +4919,188 @@ export async function runTestRun(
49194919 return finalRun ;
49204920}
49214921
4922+ /** One row of the `test wait <run-id...>` multi-run payload. */
4923+ export interface CliMultiWaitResult {
4924+ runId : string ;
4925+ /** Terminal run status, or 'timeout', or 'error:<CODE>' when the poll failed. */
4926+ status : string ;
4927+ /** Test the run belongs to, when the poll observed it. */
4928+ testId ?: string ;
4929+ }
4930+
4931+ export interface RunTestWaitManyOptions extends CommonOptions {
4932+ runIds : string [ ] ;
4933+ timeoutSeconds : number ;
4934+ maxConcurrency : number ;
4935+ }
4936+
4937+ /**
4938+ * `test wait <run-id...>` with two or more ids: attach to N already-dispatched
4939+ * runs in ONE invocation. This closes the loop the CLI itself opens: every
4940+ * batch/closure timeout prints one `testsprite test wait <runId>` hint PER
4941+ * member, which previously meant N sequential blocking invocations. The runs
4942+ * are polled concurrently under a bounded pool with ONE shared deadline
4943+ * (`--timeout` bounds the whole invocation, not each member), each member's
4944+ * poll is total (a transient error on one run never discards the others), and
4945+ * the exit code is the worst status across members: auth errors escalate to
4946+ * exit 3, any timeout or poll error exits 7, any non-passed terminal exits 1.
4947+ * Distinct from a run journal (issue #80): no persistence, just N known ids.
4948+ */
4949+ export async function runTestWaitMany (
4950+ opts : RunTestWaitManyOptions ,
4951+ deps : TestDeps = { } ,
4952+ ) : Promise < { results : CliMultiWaitResult [ ] ; summary : Record < string , number > } > {
4953+ const out = makeOutput ( opts . output , deps ) ;
4954+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
4955+
4956+ if ( opts . dryRun ) {
4957+ emitDryRunBanner ( stderrFn ) ;
4958+ const results : CliMultiWaitResult [ ] = opts . runIds . map ( runId => ( {
4959+ runId,
4960+ status : 'passed' ,
4961+ } ) ) ;
4962+ const payload = {
4963+ results,
4964+ summary : { total : results . length , passed : results . length , failed : 0 , timedOut : 0 , errors : 0 } ,
4965+ } ;
4966+ out . print ( payload , ( ) => results . map ( r => `${ r . runId } ${ r . status } ` ) . join ( '\n' ) ) ;
4967+ return payload ;
4968+ }
4969+
4970+ const client = makeClient (
4971+ { ...opts , requestTimeoutMs : resolveWaitRequestTimeoutMs ( { ...opts , wait : true } ) } ,
4972+ deps ,
4973+ ) ;
4974+ const ticker = createTicker ( stderrFn , opts . output === 'json' ? false : undefined ) ;
4975+
4976+ // One shared deadline across every member (the whole point of the shared
4977+ // pool: `--timeout 600` means the invocation ends within ~600s, not
4978+ // 600s x ceil(N/concurrency)).
4979+ const deadlineMs = Date . now ( ) + opts . timeoutSeconds * 1000 ;
4980+
4981+ type WaitOutcome =
4982+ | { kind : 'result' ; run : RunResponse }
4983+ | { kind : 'timeout' }
4984+ | { kind : 'error' ; code : string ; exitCode : number } ;
4985+
4986+ const pollOne = async ( runId : string ) : Promise < WaitOutcome > => {
4987+ const resolveAlternate = makeBackendWaitFallback ( {
4988+ client,
4989+ resolveTestId : run => run . testId ,
4990+ resolveNotBefore : run => run . createdAt ,
4991+ onResolved : ( ) => undefined ,
4992+ } ) ;
4993+ try {
4994+ const run = await pollRunUntilTerminal ( client , runId , {
4995+ timeoutSeconds : Math . max ( 1 , Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ) ,
4996+ sleep : deps . sleep ,
4997+ onTransition : opts . verbose ? ( msg : string ) => stderrFn ( `[verbose] ${ msg } ` ) : undefined ,
4998+ onTick : ( run , elapsedMs ) => {
4999+ const elapsed = Math . round ( elapsedMs / 1000 ) ;
5000+ ticker . update ( `Run ${ run . runId } — ${ run . status } (elapsed=${ elapsed } s)` ) ;
5001+ } ,
5002+ resolveAlternate,
5003+ } ) ;
5004+ return { kind : 'result' , run } ;
5005+ } catch ( err ) {
5006+ if ( err instanceof TimeoutError ) return { kind : 'timeout' } ;
5007+ if ( err instanceof RequestTimeoutError ) throw err ;
5008+ if ( err instanceof ApiError ) return { kind : 'error' , code : err . code , exitCode : err . exitCode } ;
5009+ return { kind : 'error' , code : 'TRANSPORT' , exitCode : 10 } ;
5010+ }
5011+ } ;
5012+
5013+ const outcomes = new Map < string , WaitOutcome > ( ) ;
5014+ let inFlight = 0 ;
5015+ let nextIdx = 0 ;
5016+ try {
5017+ await new Promise < void > ( ( resolve , reject ) => {
5018+ const startNext = ( ) : void => {
5019+ while ( inFlight < opts . maxConcurrency && nextIdx < opts . runIds . length ) {
5020+ const runId = opts . runIds [ nextIdx ++ ] ! ;
5021+ inFlight ++ ;
5022+ pollOne ( runId )
5023+ . then ( outcome => {
5024+ outcomes . set ( runId , outcome ) ;
5025+ inFlight -- ;
5026+ startNext ( ) ;
5027+ if ( inFlight === 0 && nextIdx >= opts . runIds . length ) resolve ( ) ;
5028+ } )
5029+ // pollOne is total except for RequestTimeoutError (handled below).
5030+ . catch ( reject ) ;
5031+ }
5032+ } ;
5033+ startNext ( ) ;
5034+ if ( opts . runIds . length === 0 ) resolve ( ) ;
5035+ } ) ;
5036+ } catch ( fanOutErr ) {
5037+ if ( fanOutErr instanceof RequestTimeoutError ) {
5038+ // Same contract as the batch pollers: leave stdout parseable before
5039+ // exiting 7 — every dispatched id is re-attachable in one command.
5040+ ticker . finalize ( 'Multi-run wait — request timed out' ) ;
5041+ const partial = {
5042+ results : opts . runIds . map ( runId => ( { runId, status : 'running' } ) ) ,
5043+ summary : { total : opts . runIds . length } ,
5044+ } ;
5045+ out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } running` ) . join ( '\n' ) ) ;
5046+ stderrFn ( `Re-attach with: testsprite test wait ${ opts . runIds . join ( ' ' ) } ` ) ;
5047+ }
5048+ throw fanOutErr ;
5049+ }
5050+ ticker . finalize ( ) ;
5051+
5052+ const results : CliMultiWaitResult [ ] = opts . runIds . map ( runId => {
5053+ const outcome = outcomes . get ( runId ) ;
5054+ if ( outcome === undefined || outcome . kind === 'timeout' ) return { runId, status : 'timeout' } ;
5055+ if ( outcome . kind === 'error' ) return { runId, status : `error:${ outcome . code } ` } ;
5056+ return { runId, status : outcome . run . status , testId : outcome . run . testId } ;
5057+ } ) ;
5058+ const passed = results . filter ( r => r . status === 'passed' ) . length ;
5059+ const timedOut = results . filter ( r => r . status === 'timeout' ) . length ;
5060+ const errors = results . filter ( r => r . status . startsWith ( 'error:' ) ) . length ;
5061+ const failed = results . length - passed - timedOut - errors ;
5062+ const payload = {
5063+ results,
5064+ summary : { total : results . length , passed, failed, timedOut, errors } ,
5065+ } ;
5066+ out . print ( payload , ( ) =>
5067+ [
5068+ ...results . map ( r => `${ r . runId } ${ r . status } ` ) ,
5069+ '' ,
5070+ `${ passed } /${ results . length } passed, ${ failed } failed/blocked, ${ timedOut } timed out, ${ errors } poll errors` ,
5071+ ] . join ( '\n' ) ,
5072+ ) ;
5073+
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 ( ' ' ) } ` ) ;
5077+ }
5078+
5079+ // Worst-status exit: auth escalates (a rejected key fails every member the
5080+ // same way), then timeout/poll-error (7, resumable), then plain failure (1).
5081+ const authError = [ ...outcomes . values ( ) ] . find (
5082+ o =>
5083+ o . kind === 'error' &&
5084+ ( o . code === 'AUTH_REQUIRED' || o . code === 'AUTH_INVALID' || o . code === 'AUTH_FORBIDDEN' ) ,
5085+ ) ;
5086+ if ( authError !== undefined && authError . kind === 'error' ) {
5087+ throw new CLIError (
5088+ `Multi-run wait: authentication failed (${ authError . code } )` ,
5089+ authError . exitCode ,
5090+ ) ;
5091+ }
5092+ if ( timedOut > 0 || errors > 0 ) {
5093+ throw new CLIError (
5094+ `Multi-run wait: ${ timedOut } timed out, ${ errors } poll error(s) out of ${ results . length } runs` ,
5095+ 7 ,
5096+ ) ;
5097+ }
5098+ if ( failed > 0 ) {
5099+ throw new CLIError ( `Multi-run wait: ${ failed } run(s) finished non-passed` , 1 ) ;
5100+ }
5101+ return payload ;
5102+ }
5103+
49225104/**
49235105 * `test wait <run-id>` — M3.3 piece-3.
49245106 *
@@ -7515,26 +7697,52 @@ export function createTestCommand(deps: TestDeps = {}): Command {
75157697 } ) ;
75167698
75177699 test
7518- . command ( 'wait <run-id>' )
7700+ . command ( 'wait <run-id... >' )
75197701 . description (
7520- 'Wait for a run to reach a terminal status.\n' +
7702+ 'Wait for one or more runs to reach a terminal status.\n' +
7703+ '\nWith several run-ids the runs are polled concurrently under one shared\n' +
7704+ '--timeout and a {results, summary} envelope is printed (worst status wins\n' +
7705+ 'the exit code), so every re-attach hint the CLI prints can be pasted as\n' +
7706+ 'ONE command.\n' +
75217707 '\nExit codes:\n' +
75227708 ' 0 passed\n' +
75237709 ' 1 failed / blocked / cancelled\n' +
75247710 ' 3 auth error\n' +
75257711 ' 4 run not found\n' +
7526- ' 7 timeout — resume with: testsprite test wait <run-id>\n' +
7712+ ' 7 timeout — resume with: testsprite test wait <run-id... >\n' +
75277713 ' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
75287714 '\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>' ,
75297715 )
75307716 . option ( '--timeout <s>' , `max seconds to wait (1–3600, default ${ DEFAULT_RUN_TIMEOUT_SECONDS } )` )
7717+ . option (
7718+ '--max-concurrency <n>' ,
7719+ 'with several run-ids, max concurrent polls (1-100, default: 10)' ,
7720+ )
75317721 . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7532- . action ( async ( runId : string , cmdOpts : WaitFlagOpts , command : Command ) => {
7533- await runTestWait (
7722+ . action ( async ( runIds : string [ ] , cmdOpts : WaitFlagOpts , command : Command ) => {
7723+ // One id keeps the historical single-run path byte-identical (same
7724+ // output shape, same exit codes); two or more fan out.
7725+ if ( runIds . length === 1 ) {
7726+ await runTestWait (
7727+ {
7728+ ...resolveCommonOptions ( command ) ,
7729+ runId : runIds [ 0 ] ! ,
7730+ timeoutSeconds : parseTimeoutFlag ( cmdOpts . timeout , 'timeout' ) ,
7731+ } ,
7732+ deps ,
7733+ ) ;
7734+ return ;
7735+ }
7736+ const maxConcurrency = parseNumericFlag ( cmdOpts . maxConcurrency , 'max-concurrency' ) ?? 10 ;
7737+ if ( ! Number . isInteger ( maxConcurrency ) || maxConcurrency < 1 || maxConcurrency > 100 ) {
7738+ throw localValidationError ( 'max-concurrency' , 'must be an integer between 1 and 100' ) ;
7739+ }
7740+ await runTestWaitMany (
75347741 {
75357742 ...resolveCommonOptions ( command ) ,
7536- runId ,
7743+ runIds ,
75377744 timeoutSeconds : parseTimeoutFlag ( cmdOpts . timeout , 'timeout' ) ,
7745+ maxConcurrency,
75387746 } ,
75397747 deps ,
75407748 ) ;
@@ -7669,6 +7877,7 @@ interface RunFlagOpts {
76697877
76707878interface WaitFlagOpts {
76717879 timeout ?: string ;
7880+ maxConcurrency ?: string ;
76727881}
76737882
76747883interface RerunFlagOpts {
0 commit comments