@@ -10,7 +10,7 @@ import { tmpdir, platform } from 'node:os';
1010import { isAbsolute , join } from 'node:path' ;
1111
1212/** Debug log helper - writes to file to avoid TUI interference */
13- function debugLog ( msg : string ) : void {
13+ export function debugLog ( msg : string ) : void {
1414 // Always log during debugging phase (TODO: restore RALPH_DEBUG check later)
1515 try {
1616 const logPath = join ( tmpdir ( ) , 'ralph-agent-debug.log' ) ;
@@ -175,6 +175,10 @@ interface RunningExecution {
175175 reject : ( error : Error ) => void ;
176176 timeoutId ?: ReturnType < typeof setTimeout > ;
177177 options ?: AgentExecuteOptions ;
178+ drainFallbackId ?: ReturnType < typeof setTimeout > ;
179+ exitCode ?: number | null ;
180+ exitSignal ?: NodeJS . Signals | null ;
181+ exitAt ?: number ;
178182}
179183
180184/**
@@ -201,6 +205,12 @@ const MAX_EXECUTION_STREAM_CHARS = 2_000_000;
201205 */
202206const STREAM_TRUNCATED_PREFIX = '[...agent output truncated in memory...]\n' ;
203207
208+ // Balances capturing late descendant output against delaying completed agents.
209+ export const STDIO_DRAIN_GRACE_PERIOD_MS = 2000 ;
210+
211+ // Output arriving this long after exit is attributed to a descendant, not the agent.
212+ export const STDIO_DRAIN_MAX_WAIT_MS = 10_000 ;
213+
204214/**
205215 * Append chunk data while enforcing an in-memory size cap.
206216 * Keeps the most recent content (tail) to preserve completion markers near the end.
@@ -713,6 +723,9 @@ export abstract class BaseAgentPlugin implements AgentPlugin {
713723 MAX_EXECUTION_STREAM_CHARS
714724 ) ;
715725 options ?. onStdout ?.( text ) ;
726+ if ( execution . drainFallbackId !== undefined ) {
727+ this . scheduleDrainFallback ( execution ) ;
728+ }
716729 } ) ;
717730
718731 // Handle stderr
@@ -724,6 +737,9 @@ export abstract class BaseAgentPlugin implements AgentPlugin {
724737 MAX_EXECUTION_STREAM_CHARS
725738 ) ;
726739 options ?. onStderr ?.( text ) ;
740+ if ( execution . drainFallbackId !== undefined ) {
741+ this . scheduleDrainFallback ( execution ) ;
742+ }
727743 } ) ;
728744
729745 // Handle process error
@@ -738,18 +754,8 @@ export abstract class BaseAgentPlugin implements AgentPlugin {
738754 debugLog ( `[DEBUG] Process close: code=${ code } , signal=${ signal } , execId=${ executionId } ` ) ;
739755 }
740756
741- // Determine status
742- let status : AgentExecutionStatus ;
743- if ( execution . interrupted ) {
744- status = 'interrupted' ;
745- } else if ( signal === 'SIGTERM' || signal === 'SIGKILL' ) {
746- status = execution . timeoutId ? 'timeout' : 'interrupted' ;
747- } else if ( code === 0 ) {
748- status = 'completed' ;
749- } else {
750- status = 'failed' ;
751- }
752-
757+ this . clearDrainFallback ( execution ) ;
758+ const status = this . deriveExitStatus ( execution , code , signal ) ;
753759 this . completeExecution ( executionId , status , code ?? undefined ) ;
754760 } ) ;
755761
@@ -758,8 +764,10 @@ export abstract class BaseAgentPlugin implements AgentPlugin {
758764 if ( process . env . RALPH_DEBUG ) {
759765 debugLog ( `[DEBUG] Process exit: code=${ code } , signal=${ signal } , execId=${ executionId } ` ) ;
760766 }
761- // Note: We don't call completeExecution here to avoid double-completion
762- // 'close' should fire after 'exit' once stdio streams are closed
767+ execution . exitCode = code ;
768+ execution . exitSignal = signal ;
769+ execution . exitAt = Date . now ( ) ;
770+ this . scheduleDrainFallback ( execution ) ;
763771 } ) ;
764772
765773 // Set up timeout if specified
@@ -895,6 +903,71 @@ export abstract class BaseAgentPlugin implements AgentPlugin {
895903 } ;
896904 }
897905
906+ /**
907+ * Derive the final execution status from process termination details.
908+ */
909+ private deriveExitStatus (
910+ execution : RunningExecution ,
911+ code : number | null ,
912+ signal : NodeJS . Signals | null
913+ ) : AgentExecutionStatus {
914+ if ( execution . interrupted ) {
915+ return 'interrupted' ;
916+ }
917+ if ( signal === 'SIGTERM' || signal === 'SIGKILL' ) {
918+ return execution . timeoutId ? 'timeout' : 'interrupted' ;
919+ }
920+ return code === 0 ? 'completed' : 'failed' ;
921+ }
922+
923+ /**
924+ * Cancel a pending fallback timer for an execution.
925+ */
926+ private clearDrainFallback ( execution : RunningExecution ) : void {
927+ if ( execution . drainFallbackId !== undefined ) {
928+ clearTimeout ( execution . drainFallbackId ) ;
929+ execution . drainFallbackId = undefined ;
930+ }
931+ }
932+
933+ /**
934+ * Schedule bounded completion after an exited process's stdio goes quiet.
935+ */
936+ private scheduleDrainFallback ( execution : RunningExecution ) : void {
937+ this . clearDrainFallback ( execution ) ;
938+
939+ const remainingBudget =
940+ ( execution . exitAt ?? Date . now ( ) ) + STDIO_DRAIN_MAX_WAIT_MS - Date . now ( ) ;
941+ const delay = Math . min ( STDIO_DRAIN_GRACE_PERIOD_MS , remainingBudget ) ;
942+ const finalize = ( ) : void => {
943+ execution . drainFallbackId = undefined ;
944+ if ( ! this . executions . has ( execution . executionId ) ) {
945+ return ;
946+ }
947+
948+ if ( process . env . RALPH_DEBUG ) {
949+ debugLog (
950+ `[DEBUG] Process stdio stayed open after drain: code=${ execution . exitCode } , ` +
951+ `signal=${ execution . exitSignal } , execId=${ execution . executionId } `
952+ ) ;
953+ }
954+ const status = this . deriveExitStatus (
955+ execution ,
956+ execution . exitCode ?? null ,
957+ execution . exitSignal ?? null
958+ ) ;
959+ this . completeExecution ( execution . executionId , status , execution . exitCode ?? undefined ) ;
960+ execution . process . stdout ?. destroy ( ) ;
961+ execution . process . stderr ?. destroy ( ) ;
962+ } ;
963+
964+ if ( delay <= 0 ) {
965+ finalize ( ) ;
966+ return ;
967+ }
968+ execution . drainFallbackId = setTimeout ( finalize , delay ) ;
969+ }
970+
898971 /**
899972 * Complete an execution and resolve its promise.
900973 */
@@ -920,6 +993,7 @@ export abstract class BaseAgentPlugin implements AgentPlugin {
920993 if ( execution . timeoutId ) {
921994 clearTimeout ( execution . timeoutId ) ;
922995 }
996+ this . clearDrainFallback ( execution ) ;
923997
924998 const endedAt = new Date ( ) ;
925999 const durationMs = endedAt . getTime ( ) - execution . startedAt . getTime ( ) ;
0 commit comments