#!/usr/bin/env bun /** * test-paid-shards — enumerate, shard, and run the paid (gate/periodic) tier. * * The single-process `test:gate` fan-out has never completed a run: one wedged * or spinning file takes the whole tier down, and an in-process `--timeout` * cannot save it because a spinning main thread never fires a timer. This * runner applies the free tier's proven fix — one Bun process per shard — plus * the two things the paid tier additionally needs: * * - an EXTERNAL wall-clock timeout that kills the shard's process GROUP, and * - an aggregate that distinguishes failed from timed-out from never-started, * so 26% execution can never again look like a pass. * * Why not Bun 1.3.13's native `--shard` / isolated runs? Three gaps, each one * fatal for this tier: * 1. No detached-process-group SIGKILL. Paid tests spawn `claude` / `codex` * PTY grandchildren; when a shard hangs, in-process isolation kills the * Bun worker but the grandchildren survive and burn cores for hours. * 2. No never-started taxonomy. A run that aborts partway reports only what * executed — the shards that never ran are invisible, which is exactly * the 26%-execution-looks-like-a-pass bug. * 3. No per-shard env / eval dir. Each shard needs its own GSTACK_EVAL_DIR * so eval baselines are per-test-file instead of last-flush-wins. * * Worst-case wall clock = ceil(shards / jobs) × shard timeout. At the time of * writing: gate is 44 one-file shards → ceil(44/4) × 30min = 5.5h; periodic is * 63 → 8h. Do NOT hand-derive the eval:bg:* detach timeouts from a snapshot of * these counts — test/eval-detach-timeout-floor.test.ts recomputes the bound * from the live shard census every run and fails CI if package.json's numbers * dip below it (undersized detach timeouts recreate never-started truncation). * * Env contract: EVALS_JOBS = how many shard PROCESSES run at once (this * runner). EVALS_CONCURRENCY = bun's --max-concurrency WITHIN a shard (and the * legacy single-process scripts). They were previously conflated: exporting * the legacy value 15 gave you 15 concurrent Bun processes each spawning * claude — the 429 storm. * * Enumeration matches package.json's `test:gate` globs (via the shared * test/helpers/paid-test-set.ts) and honors EVALS_TIER against the E2E_TIERS * map in test/helpers/touchfiles.ts. Output classification reuses * scripts/test-strict-output.ts rather than reimplementing it. * * Parallelism now lives ACROSS shards (--jobs), not inside one Bun process, so * each shard runs its own file sequentially and can be killed independently. * * Usage: * bun run scripts/test-paid-shards.ts --list # shard plan only * bun run scripts/test-paid-shards.ts --tier gate # run gate tier * bun run scripts/test-paid-shards.ts --timeout 600 --jobs 2 */ import { spawn, type ChildProcess } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { normalizeRelativePath } from './test-free-shards'; import { BunTestOutputClassifier, exactTestFileSelectors, forwardAndClassify, installChildSignalForwarding, killProcessGroup, strictTestExitCode, } from './test-strict-output'; import { PAID_TEST_GLOBS, isPaidTestFile } from '../test/helpers/paid-test-set'; import { getProjectEvalDir } from '../test/helpers/eval-store'; export { PAID_TEST_GLOBS, isPaidTestFile }; const ROOT = path.resolve(import.meta.dir, '..'); export type PaidTier = 'gate' | 'periodic'; export const DEFAULT_TIER: PaidTier = 'gate'; export const DEFAULT_SHARD_TIMEOUT_MS = 30 * 60_000; export const DEFAULT_MAX_FILES_PER_SHARD = 1; export const DEFAULT_JOBS = 4; // Within one shard's bun process. 4 jobs × 4 ≈ the legacy single-process // default of 15, keeping total in-flight `claude` sessions inside known-safe // API rate headroom. export const DEFAULT_WITHIN_SHARD_CONCURRENCY = 4; export function collectPaidTestFiles(rootDir = ROOT): string[] { const testDir = path.join(rootDir, 'test'); if (!fs.existsSync(testDir)) return []; return fs.readdirSync(testDir) .map((name) => `test/${name}`) .filter(isPaidTestFile) .sort(); } export interface TierClassification { included: boolean; reason: string; } /** * Decide whether a paid test file has anything to run in `tier`. * * Per-TEST tier filtering already happens at runtime: test/helpers/e2e-helpers.ts * intersects the selected tests with E2E_TIERS whenever EVALS_TIER is set, and * this runner passes EVALS_TIER down to every shard. So this file-level pass is * only an optimization — skipping a file merely saves one near-instant shard. * * Exclusion is the dangerous direction (a wrongly-skipped gate test is exactly * the invisible-non-execution bug this runner exists to kill), so the only * exclusion evidence accepted is an explicit whole-file `EVALS_TIER === ''` * guard. Inferring a file's tier from which E2E_TIERS names appear in its source * is guesswork that silently drops real work: short keys like 'retro' match * unrelated strings, and LLM-judge tests are keyed off LLM_JUDGE_TOUCHFILES and * carry no E2E_TIERS name at all. Everything without an explicit other-tier * guard runs and self-skips. */ export function classifyPaidTestFile(source: string, tier: PaidTier): TierClassification { const other: PaidTier = tier === 'gate' ? 'periodic' : 'gate'; const declares = (candidate: PaidTier) => new RegExp(`EVALS_TIER\\s*===\\s*['"\`]${candidate}['"\`]`).test(source); if (declares(tier)) return { included: true, reason: `declares EVALS_TIER === '${tier}'` }; if (declares(other)) return { included: false, reason: `declares EVALS_TIER === '${other}' only` }; return { included: true, reason: 'no whole-file tier guard — runtime E2E_TIERS filter decides' }; } export interface TierSelection { selected: string[]; excluded: Array<{ file: string; reason: string }>; } export function selectPaidTestFiles(files: string[], tier: PaidTier, rootDir = ROOT): TierSelection { const selected: string[] = []; const excluded: Array<{ file: string; reason: string }> = []; for (const file of files) { const source = fs.readFileSync(path.join(rootDir, file), 'utf8'); const classification = classifyPaidTestFile(source, tier); if (classification.included) selected.push(file); else excluded.push({ file, reason: classification.reason }); } return { selected, excluded }; } export function planPaidShards( files: string[], options: { maxFilesPerShard?: number } = {}, ): string[][] { const size = Math.max(1, options.maxFilesPerShard ?? DEFAULT_MAX_FILES_PER_SHARD); const unique = [...new Set(files.map(normalizeRelativePath))].sort(); const shards: string[][] = []; for (let index = 0; index < unique.length; index += size) shards.push(unique.slice(index, index + size)); return shards; } export function buildPaidShardArgs( files: string[], timeoutMs: number, maxConcurrency: number = DEFAULT_WITHIN_SHARD_CONCURRENCY, ): string[] { // Explicit --concurrent/--max-concurrency: the legacy path always set one; // omitting it here made within-shard parallelism differ silently between // the two runners (observed: 1.6x sumdur/wall sharded vs 8x legacy). return ['test', ...files, '--retry', '1', '--concurrent', `--max-concurrency=${maxConcurrency}`, `--timeout=${timeoutMs}`]; } /** * Stable per-shard eval-dir slug: test filename sans extension, sanitized. * Stable across runs so each shard baselines against its own prior run. */ export function shardSlug(files: string[]): string { return files .map((file) => path.basename(normalizeRelativePath(file)).replace(/\.test\.(?:[cm]?[jt]s|tsx|jsx)$/, '')) .join('+') .replace(/[^a-zA-Z0-9._+-]/g, '-'); } export type ShardStatus = 'passed' | 'failed' | 'timed-out' | 'never-started'; export interface ShardOutcome { shard: number; files: string[]; status: ShardStatus; exitCode: number | null; elapsedMs: number; groupPid: number | null; } export interface ShardCommand { command: string; args: string[]; } export interface RunShardsOptions { timeoutMs?: number; jobs?: number; /** bun --max-concurrency inside each shard (EVALS_CONCURRENCY). */ withinShardConcurrency?: number; rootDir?: string; env?: NodeJS.ProcessEnv; /** When set, each shard child gets GSTACK_EVAL_DIR=/shards//. */ evalDirBase?: string; /** Override the spawned command. Tests inject fake slow/spinning commands. */ commandFor?: (files: string[]) => ShardCommand; log?: (line: string) => void; } export async function runPaidShard( files: string[], shardNumber: number, totalShards: number, options: RunShardsOptions = {}, ): Promise { if (files.length === 0) throw new Error('Cannot run an empty paid-test shard.'); const rootDir = options.rootDir ?? ROOT; const timeoutMs = options.timeoutMs ?? DEFAULT_SHARD_TIMEOUT_MS; const streamLive = (options.jobs ?? DEFAULT_JOBS) === 1; const log = options.log ?? ((line: string) => console.log(line)); const label = `[test:paid] shard ${shardNumber}/${totalShards}`; const { command, args } = options.commandFor ? options.commandFor(files) : { command: process.execPath, args: buildPaidShardArgs( exactTestFileSelectors(files, rootDir), timeoutMs, options.withinShardConcurrency ?? DEFAULT_WITHIN_SHARD_CONCURRENCY, ), }; const env = { ...(options.env ?? process.env) }; if (options.evalDirBase) { env.GSTACK_EVAL_DIR = path.join(options.evalDirBase, 'shards', shardSlug(files)); } const startedAt = Date.now(); log(`${label} START ${files.join(' ')} (timeout ${Math.round(timeoutMs / 1000)}s)`); const child = spawn(command, args, { cwd: rootDir, env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', windowsHide: true, }); const groupPid = child.pid ?? null; // Group-kill on parent SIGINT/SIGTERM too, not just on timeout. const forwarding = installChildSignalForwarding({ kill: (signal?: NodeJS.Signals | number) => { killProcessGroup(child, (signal as NodeJS.Signals) ?? 'SIGTERM'); return true; }, }); const classifier = new BunTestOutputClassifier(); const buffered: Buffer[] = []; const sink = (destination: NodeJS.WriteStream): NodeJS.WriteStream => (streamLive ? destination : ({ write: (chunk: Buffer | string) => buffered.push(Buffer.from(chunk)) } as unknown as NodeJS.WriteStream)); let timedOut = false; const killTimer = setTimeout(() => { timedOut = true; killProcessGroup(child, 'SIGKILL'); }, timeoutMs); let exitCode: number | null = null; try { const streams: Array> = []; if (child.stdout) streams.push(forwardAndClassify(child.stdout, sink(process.stdout), classifier)); if (child.stderr) streams.push(forwardAndClassify(child.stderr, sink(process.stderr), classifier)); exitCode = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (code) => resolve(code)); }); await Promise.all(streams); } finally { clearTimeout(killTimer); forwarding.dispose(); // Reap survivors of this shard even on the clean path. killProcessGroup(child, 'SIGKILL'); } const summary = classifier.end(); if (!streamLive && buffered.length > 0) process.stdout.write(Buffer.concat(buffered)); // Pass expectedFiles so a shard whose bun child ran fewer files than planned // (or zero, all self-skipped) with exit 0 is NOT recorded 'passed' — the // invisible-non-execution class this runner exists to kill. bun prints // "Ran N tests across M files" with M = selected files even when every test // self-skips, so terminalFileCounts must include files.length. Only enforced // on the real bun path: an injected commandFor (tests) isn't bun and emits no // terminal summary, so there's no file count to check against. const expectedFiles = options.commandFor ? undefined : files.length; const status: ShardStatus = timedOut ? 'timed-out' : strictTestExitCode(exitCode ?? 1, summary, expectedFiles) === 0 ? 'passed' : 'failed'; const elapsedMs = Date.now() - startedAt; log(`${label} ${status.toUpperCase()} in ${Math.round(elapsedMs / 1000)}s (exit ${exitCode ?? 'signal'})`); return { shard: shardNumber, files, status, exitCode, elapsedMs, groupPid }; } export interface RunSummary { total: number; executed: number; passed: number; failed: number; timedOut: number; neverStarted: number; outcomes: ShardOutcome[]; } export function summarize(outcomes: ShardOutcome[]): RunSummary { const count = (status: ShardStatus) => outcomes.filter((o) => o.status === status).length; return { total: outcomes.length, executed: outcomes.length - count('never-started'), passed: count('passed'), failed: count('failed'), timedOut: count('timed-out'), neverStarted: count('never-started'), outcomes, }; } /** Run every shard in its own process. A timeout or failure never aborts the run. */ export async function runPaidShards( shards: string[][], options: RunShardsOptions = {}, ): Promise { const jobs = Math.max(1, options.jobs ?? DEFAULT_JOBS); const outcomes: ShardOutcome[] = shards.map((files, index) => ({ shard: index + 1, files, status: 'never-started', exitCode: null, elapsedMs: 0, groupPid: null, })); let next = 0; const worker = async (): Promise => { while (true) { const index = next; next += 1; if (index >= shards.length) return; try { outcomes[index] = await runPaidShard(shards[index], index + 1, shards.length, { ...options, jobs }); } catch (error) { outcomes[index] = { shard: index + 1, files: shards[index], status: 'failed', exitCode: null, elapsedMs: 0, groupPid: null, }; console.error(`[test:paid] shard ${index + 1} could not run: ${error instanceof Error ? error.message : String(error)}`); } } }; await Promise.all(Array.from({ length: Math.min(jobs, shards.length) }, worker)); return summarize(outcomes); } export function formatSummary(summary: RunSummary): string[] { const lines = [ '', `[test:paid] ${summary.executed}/${summary.total} shards executed — ` + `${summary.passed} passed, ${summary.failed} failed, ` + `${summary.timedOut} timed out, ${summary.neverStarted} never started`, ]; for (const outcome of summary.outcomes) { lines.push( ` ${outcome.status.padEnd(13)} ${String(Math.round(outcome.elapsedMs / 1000)).padStart(5)}s ` + outcome.files.join(' '), ); } return lines; } type CliOptions = { tier: PaidTier; listOnly: boolean; timeoutMs: number; jobs: number; withinShardConcurrency: number; maxFilesPerShard: number; }; function parsePositiveInt(value: string | undefined, flag: string): number { const parsed = Number.parseInt(value ?? '', 10); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} needs a positive integer. Received: ${value}`); return parsed; } function validatedTier(value: string | undefined, source: string): PaidTier { if (value === undefined || value === '') return DEFAULT_TIER; // A typo'd EVALS_TIER (e.g. 'e2e', the tier string eval-store uses) would // otherwise cast through unchecked, match nothing in the runtime E2E_TIERS // filter, self-skip every test, and exit 0 with all shards 'passed' — the // exact 0%-execution-looks-like-a-pass class this runner exists to kill. if (value !== 'gate' && value !== 'periodic') { throw new Error(`${source} must be gate or periodic. Received: ${value}`); } return value; } export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv = process.env): CliOptions { const options: CliOptions = { tier: validatedTier(env.EVALS_TIER, 'EVALS_TIER'), listOnly: false, timeoutMs: env.EVALS_SHARD_TIMEOUT_MS ? parsePositiveInt(env.EVALS_SHARD_TIMEOUT_MS, 'EVALS_SHARD_TIMEOUT_MS') : DEFAULT_SHARD_TIMEOUT_MS, // EVALS_JOBS = shard process count. EVALS_CONCURRENCY deliberately does // NOT set jobs anymore — it's bun's within-shard --max-concurrency (its // legacy meaning). Conflating them turned "EVALS_CONCURRENCY=15" into 15 // parallel Bun processes each spawning claude. jobs: env.EVALS_JOBS ? parsePositiveInt(env.EVALS_JOBS, 'EVALS_JOBS') : DEFAULT_JOBS, withinShardConcurrency: env.EVALS_CONCURRENCY ? parsePositiveInt(env.EVALS_CONCURRENCY, 'EVALS_CONCURRENCY') : DEFAULT_WITHIN_SHARD_CONCURRENCY, maxFilesPerShard: DEFAULT_MAX_FILES_PER_SHARD, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--list') { options.listOnly = true; continue; } if (arg === '--tier') { const value = argv[index += 1]; if (value !== 'gate' && value !== 'periodic') throw new Error(`--tier must be gate or periodic. Received: ${value}`); options.tier = value; continue; } if (arg === '--timeout') { options.timeoutMs = parsePositiveInt(argv[index += 1], '--timeout') * 1000; continue; } if (arg === '--jobs') { options.jobs = parsePositiveInt(argv[index += 1], '--jobs'); continue; } if (arg === '--files-per-shard') { options.maxFilesPerShard = parsePositiveInt(argv[index += 1], '--files-per-shard'); continue; } throw new Error(`Unknown argument: ${arg}`); } return options; } async function main(): Promise { const options = parseCliOptions(process.argv.slice(2)); const discovered = collectPaidTestFiles(); if (discovered.length === 0) throw new Error('No paid test files were discovered.'); const { selected, excluded } = selectPaidTestFiles(discovered, options.tier); const shards = planPaidShards(selected, { maxFilesPerShard: options.maxFilesPerShard }); console.log( `[test:paid] tier=${options.tier}: ${selected.length}/${discovered.length} files, ` + `${shards.length} shards, jobs=${options.jobs}, timeout=${Math.round(options.timeoutMs / 1000)}s`, ); if (options.listOnly) { for (let index = 0; index < shards.length; index += 1) { console.log(` shard ${index + 1}/${shards.length}: ${shards[index].join(' ')}`); } if (excluded.length > 0) { console.log(`\nExcluded (${excluded.length}):`); for (const { file, reason } of excluded) console.log(` - ${file} [${reason}]`); } return 0; } const summary = await runPaidShards(shards, { // Tier reaches the children only via EVALS_TIER below; the runtime // E2E_TIERS filter inside each child is the real selection mechanism. timeoutMs: options.timeoutMs, jobs: options.jobs, withinShardConcurrency: options.withinShardConcurrency, env: { ...process.env, EVALS: '1', EVALS_TIER: options.tier }, evalDirBase: process.env.GSTACK_EVAL_DIR || getProjectEvalDir(), }); for (const line of formatSummary(summary)) console.log(line); return summary.passed === summary.total ? 0 : 1; } if (import.meta.main) { try { process.exitCode = await main(); } catch (error) { console.error(`[test:paid] ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } }