@@ -29,6 +29,7 @@ import type {
2929 EvalData ,
3030} from './types'
3131import type { z } from 'zod/v4'
32+ import type { ChildProcess } from 'child_process'
3233
3334disableLiveUserInputCheck ( )
3435
@@ -257,7 +258,7 @@ function getCodebuffFileStates(
257258 cwd : projectPath ,
258259 stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
259260 } )
260-
261+
261262 // Get diff of staged files to include new files
262263 return execFileSync ( 'git' , [ 'diff' , '--staged' ] , {
263264 cwd : projectPath ,
@@ -274,17 +275,88 @@ export function mockRunGitEvals(path: string) {
274275// Global concurrency limiter that can be shared across multiple repository evaluations
275276let globalConcurrencyLimiter : ReturnType < typeof pLimit > | null = null
276277
278+ // Track all active child processes for cleanup
279+ const activeChildProcesses = new Set < ChildProcess > ( )
280+ let isCleaningUp = false
281+
277282export function setGlobalConcurrencyLimit ( limit : number ) {
278283 globalConcurrencyLimiter = pLimit ( limit )
279284}
280285
286+ /**
287+ * Terminates all active evaluation child processes
288+ */
289+ export async function terminateAllEvalChildren ( ) : Promise < void > {
290+ if ( isCleaningUp || activeChildProcesses . size === 0 ) {
291+ return
292+ }
293+
294+ isCleaningUp = true
295+ console . log (
296+ `\nTerminating ${ activeChildProcesses . size } active evaluation processes...` ,
297+ )
298+
299+ const killPromises = Array . from ( activeChildProcesses ) . map ( async ( child ) => {
300+ if ( ! child . pid || child . killed ) {
301+ return
302+ }
303+
304+ try {
305+ // First try graceful termination
306+ if ( process . platform === 'win32' ) {
307+ // Windows: kill process tree
308+ execFileSync ( 'taskkill' , [ '/PID' , String ( child . pid ) , '/T' ] , {
309+ stdio : 'ignore' ,
310+ timeout : 3000 ,
311+ } )
312+ } else {
313+ // POSIX: kill process group
314+ process . kill ( - child . pid , 'SIGTERM' )
315+ }
316+
317+ // Wait a bit for graceful shutdown
318+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) )
319+
320+ // Force kill if still alive
321+ if ( ! child . killed ) {
322+ if ( process . platform === 'win32' ) {
323+ execFileSync ( 'taskkill' , [ '/F' , '/PID' , String ( child . pid ) , '/T' ] , {
324+ stdio : 'ignore' ,
325+ timeout : 1000 ,
326+ } )
327+ } else {
328+ process . kill ( - child . pid , 'SIGKILL' )
329+ }
330+ }
331+ } catch ( error ) {
332+ // Process may have already exited
333+ console . warn ( `Failed to kill process ${ child . pid } :` , error )
334+ }
335+ } )
336+
337+ await Promise . allSettled ( killPromises )
338+ activeChildProcesses . clear ( )
339+ isCleaningUp = false
340+ }
341+
281342export async function runGitEvals (
282343 evalDataPath : string ,
283344 outputDir : string ,
284345 codingAgent : 'codebuff' | 'claude' ,
285346 limit ?: number ,
286347 logToStdout : boolean = false ,
287348) : Promise < FullEvalLog > {
349+ // Set up signal handlers if this is the main module
350+ if ( require . main === module ) {
351+ const signalHandler = async ( signal : string ) => {
352+ console . log ( `\nReceived ${ signal } , cleaning up...` )
353+ await terminateAllEvalChildren ( )
354+ process . exit ( signal === 'SIGINT' ? 130 : 143 )
355+ }
356+
357+ process . on ( 'SIGINT' , ( ) => signalHandler ( 'SIGINT' ) )
358+ process . on ( 'SIGTERM' , ( ) => signalHandler ( 'SIGTERM' ) )
359+ }
288360 console . log ( `Loading eval data from: ${ evalDataPath } ` )
289361 const evalData = JSON . parse (
290362 fs . readFileSync ( evalDataPath , 'utf-8' ) ,
@@ -379,9 +451,16 @@ export async function runGitEvals(
379451 fingerprintId ,
380452 codingAgent ,
381453 ] ,
382- { stdio : [ 'pipe' , 'pipe' , 'pipe' , 'ipc' ] , env : process . env } ,
454+ {
455+ stdio : [ 'pipe' , 'pipe' , 'pipe' , 'ipc' ] ,
456+ env : process . env ,
457+ detached : true , // Create new process group for proper signal handling
458+ } ,
383459 )
384460
461+ // Track child process for cleanup
462+ activeChildProcesses . add ( child )
463+
385464 child . stdout ?. pipe ( logStream )
386465 child . stderr ?. pipe ( logStream )
387466
@@ -421,7 +500,13 @@ export async function runGitEvals(
421500 )
422501
423502 child . on ( 'exit' , ( code ) => {
424- logStream . end ( )
503+ // Remove from tracking
504+ activeChildProcesses . delete ( child )
505+
506+ if ( ! logToStdout && logStream !== process . stdout ) {
507+ logStream . end ( )
508+ }
509+
425510 if ( code !== 0 ) {
426511 console . error (
427512 `Eval process for ${ evalCommit . sha } exited with code ${ code } . See logs at ${ logPath } ` ,
0 commit comments