Add real-time progress feedback to multi-run simulations
Balance Check / balance-simulation (push) Successful in 11m24s
Balance Check / multi-run-balance (push) Successful in 26m35s
CI / build-and-push (push) Successful in 34s

Switch from exec() to spawn() for streaming stderr, add onProgress
callback to runner, and emit per-run progress lines from workers.
CI now shows live percentage, tick count, and era during long runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 17:42:37 -04:00
parent 04d8a4e883
commit db034687d6
3 changed files with 36 additions and 11 deletions
+18 -8
View File
@@ -1,4 +1,4 @@
import { exec } from 'node:child_process'; import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { resolve as pathResolve, dirname } from 'node:path'; import { resolve as pathResolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -74,20 +74,30 @@ interface WorkerResult {
function spawnWorker(runId: number, seed: number): Promise<WorkerResult> { function spawnWorker(runId: number, seed: number): Promise<WorkerResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const workerPath = new URL('./worker.ts', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'); const workerPath = new URL('./worker.ts', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
const cmd = `npx tsx "${workerPath}" --strategy ${strategyName} --ticks ${totalTicks} --seed ${seed} --run-id ${runId}`; const tsxBin = pathResolve(__dirname, '..', 'node_modules', '.bin', 'tsx');
exec(cmd, { maxBuffer: 200 * 1024 * 1024 }, (error, stdout, stderr) => { const child = spawn(tsxBin, [workerPath, '--strategy', strategyName, '--ticks', String(totalTicks), '--seed', String(seed), '--run-id', String(runId)], {
if (stderr) process.stderr.write(stderr); stdio: ['ignore', 'pipe', 'pipe'],
if (error) { });
reject(new Error(`Run #${runId} (seed ${seed}) failed: ${error.message}`));
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk; });
child.stderr.on('data', (chunk: Buffer) => { process.stderr.write(chunk); });
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Run #${runId} (seed ${seed}) exited with code ${code}`));
return; return;
} }
try { try {
const result = JSON.parse(stdout) as WorkerResult; resolve(JSON.parse(stdout) as WorkerResult);
resolve(result);
} catch (e) { } catch (e) {
reject(new Error(`Run #${runId} (seed ${seed}) produced invalid JSON: ${(e as Error).message}`)); reject(new Error(`Run #${runId} (seed ${seed}) produced invalid JSON: ${(e as Error).message}`));
} }
}); });
child.on('error', (err) => {
reject(new Error(`Run #${runId} (seed ${seed}) failed to spawn: ${err.message}`));
});
}); });
} }
+5 -1
View File
@@ -14,6 +14,7 @@ export interface SimulationConfig {
seed?: number; seed?: number;
verbose?: boolean; verbose?: boolean;
silent?: boolean; silent?: boolean;
onProgress?: (tick: number, totalTicks: number, era: string) => void;
} }
export interface EraTransition { export interface EraTransition {
@@ -139,9 +140,12 @@ export function runSimulation(config: SimulationConfig): SimulationResult {
allMetrics.push(collectMetrics(state)); allMetrics.push(collectMetrics(state));
} }
if (!config.silent && tick % progressInterval === 0) { if (tick % progressInterval === 0) {
if (!config.silent) {
printProgress(tick, config.totalTicks, state, startTime); printProgress(tick, config.totalTicks, state, startTime);
} }
config.onProgress?.(tick, config.totalTicks, state.meta.currentEra);
}
} }
if (!config.silent) { if (!config.silent) {
+12 -1
View File
@@ -23,7 +23,18 @@ const strategy = strategyName === 'random' ? new RandomStrategy()
process.stderr.write(`[Run #${runId}] Starting (seed ${seed}, ${totalTicks} ticks, ${strategyName})...\n`); process.stderr.write(`[Run #${runId}] Starting (seed ${seed}, ${totalTicks} ticks, ${strategyName})...\n`);
const result = runSimulation({ totalTicks, decisionInterval, strategy, seed, verbose: false, silent: true }); const result = runSimulation({
totalTicks,
decisionInterval,
strategy,
seed,
verbose: false,
silent: true,
onProgress: (tick, total, era) => {
const pct = ((tick / total) * 100).toFixed(0);
process.stderr.write(`[Run #${runId}] ${pct}% (tick ${tick.toLocaleString()}/${total.toLocaleString()}) — ${era}\n`);
},
});
const report = generateJsonReport(result, { totalTicks, decisionInterval, strategy, seed }); const report = generateJsonReport(result, { totalTicks, decisionInterval, strategy, seed });
const output = { const output = {