* Trigger (and optionally poll) a single testId. * * When `opts.wait` is set, a per-spec wall-clock deadline is set * immediately before the first trigger attempt. Every throttle/retry sleep * and the final `pollRunUntilTerminal` call receive only the remaining * seconds so the `--wa
(testId: string)
| 2424 | * into the result's `error` field so one failure doesn't abort siblings. |
| 2425 | */ |
| 2426 | async function triggerOne(testId: string): Promise<CliBatchRunResult> { |
| 2427 | // Mint a fresh idempotency key per run — MUST NOT reuse the create key. |
| 2428 | const runIdempotencyKey = `cli-batch-run-${randomUUID()}`; |
| 2429 | if (opts.debug) { |
| 2430 | stderrFn(`[batch-run] ${testId} idempotency-key: ${runIdempotencyKey}`); |
| 2431 | } |
| 2432 | |
| 2433 | // MAJOR 2: record the wall-clock deadline before the first trigger attempt |
| 2434 | // when --wait is set so that throttle/retry sleeps + the subsequent poll all |
| 2435 | // draw from the SAME budget. Triggering at t=0, waiting 60 s for throttle, |
| 2436 | // then starting a fresh full-timeout poll would allow the spec to consume |
| 2437 | // 2× the intended budget. |
| 2438 | const specDeadlineMs: number | undefined = opts.wait |
| 2439 | ? Date.now() + timeoutSeconds * 1000 |
| 2440 | : undefined; |
| 2441 | |
| 2442 | /** Returns remaining milliseconds until the per-spec deadline, or Infinity when no deadline. */ |
| 2443 | function remainingMs(): number { |
| 2444 | if (specDeadlineMs === undefined) return Infinity; |
| 2445 | return Math.max(0, specDeadlineMs - Date.now()); |
| 2446 | } |
| 2447 | |
| 2448 | let triggerResponse: TriggerRunResponse; |
| 2449 | |
| 2450 | // Outer RATE_LIMITED retry loop. |
| 2451 | // The batch call site passes `retryOnRateLimit: false` to `triggerRunWithMeta` |
| 2452 | // so the HTTP layer throws on the first 429 — this loop is the SOLE owner of |
| 2453 | // rate-limit handling. Single `test run` / `test create --run` still use |
| 2454 | // retryOnRateLimit: true (the default) and are unaffected by this loop. |
| 2455 | let outerRateLimitAttempt = 0; |
| 2456 | while (true) { |
| 2457 | // Fix 2: deadline check BEFORE acquiring a throttle slot or firing a trigger. |
| 2458 | // Without this guard, an outer retry could acquire a slot and send a new |
| 2459 | // POST even after the --wait deadline has already expired. |
| 2460 | if (opts.wait && remainingMs() <= 0) { |
| 2461 | return { |
| 2462 | testId, |
| 2463 | runId: '', |
| 2464 | status: 'timeout', |
| 2465 | codeVersion: '', |
| 2466 | error: { |
| 2467 | code: 'UNSUPPORTED', |
| 2468 | message: `Timed out after ${timeoutSeconds}s before trigger attempt for ${testId}.`, |
| 2469 | exitCode: 7, |
| 2470 | }, |
| 2471 | }; |
| 2472 | } |
| 2473 | |
| 2474 | // Acquire a slot in the client-side rate window before firing the trigger. |
| 2475 | // If the window is full, sleep until the oldest slot ages out — clamped to |
| 2476 | // the remaining deadline so we don't overshoot the --wait budget. |
| 2477 | let throttleWait: number; |
| 2478 | while ((throttleWait = rateThrottle.acquire()) > 0) { |
| 2479 | const clampedWait = Math.min(throttleWait, remainingMs()); |
| 2480 | if (clampedWait <= 0) { |
| 2481 | // Deadline already passed while waiting for a throttle slot. |
| 2482 | return { |
| 2483 | testId, |
no test coverage detected