Skip to content

Commit 5d986a0

Browse files
authored
Merge pull request #444 from subsy/devin/1788434576-fix-agent-exit-completion
fix(agents): complete an agent execution once the process exits
2 parents 27d0813 + c85d8e8 commit 5d986a0

9 files changed

Lines changed: 585 additions & 27 deletions

File tree

‎src/plugins/agents/base.ts‎

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { tmpdir, platform } from 'node:os';
1010
import { 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
*/
202206
const 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();

‎src/plugins/agents/droid/index.ts‎

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
import { spawn } from 'node:child_process';
77
import { randomUUID } from 'node:crypto';
88
import { platform } from 'node:os';
9-
import { BaseAgentPlugin, quoteForWindowsShell } from '../base.js';
9+
import {
10+
BaseAgentPlugin,
11+
debugLog,
12+
quoteForWindowsShell,
13+
STDIO_DRAIN_GRACE_PERIOD_MS,
14+
STDIO_DRAIN_MAX_WAIT_MS,
15+
} from '../base.js';
1016
import type {
1117
AgentPluginMeta,
1218
AgentPluginFactory,
@@ -230,24 +236,63 @@ export class DroidAgentPlugin extends BaseAgentPlugin {
230236
let stdout = '';
231237
let stderr = '';
232238
let interrupted = false;
239+
let completed = false;
233240
let timeoutId: ReturnType<typeof setTimeout> | undefined;
241+
let drainFallbackId: ReturnType<typeof setTimeout> | undefined;
242+
let exitCode: number | null = null;
243+
let exitSignal: NodeJS.Signals | null = null;
244+
let exitAt: number | undefined;
234245

235246
options?.onStart?.(executionId);
236247

248+
const clearDrainFallback = (): void => {
249+
if (drainFallbackId !== undefined) {
250+
clearTimeout(drainFallbackId);
251+
drainFallbackId = undefined;
252+
}
253+
};
254+
255+
const deriveExitStatus = (
256+
code: number | null,
257+
signal: NodeJS.Signals | null
258+
): AgentExecutionStatus => {
259+
if (interrupted) {
260+
return 'interrupted';
261+
}
262+
if (signal === 'SIGTERM' || signal === 'SIGKILL') {
263+
return timeoutId ? 'timeout' : 'interrupted';
264+
}
265+
if (code === 0) {
266+
return 'completed';
267+
}
268+
return 'failed';
269+
};
270+
237271
proc.stdout?.on('data', (data: Buffer) => {
238272
const text = data.toString();
239273
stdout += text;
240274
options?.onStdout?.(text);
275+
if (drainFallbackId !== undefined) {
276+
scheduleDrainFallback();
277+
}
241278
});
242279

243280
proc.stderr?.on('data', (data: Buffer) => {
244281
const text = data.toString();
245282
stderr += text;
246283
options?.onStderr?.(text);
284+
if (drainFallbackId !== undefined) {
285+
scheduleDrainFallback();
286+
}
247287
});
248288

249289
const complete = (status: AgentExecutionStatus, exitCode?: number, error?: string) => {
290+
if (completed) {
291+
return;
292+
}
293+
completed = true;
250294
if (timeoutId) clearTimeout(timeoutId);
295+
clearDrainFallback();
251296
const endedAt = new Date();
252297
resolvePromise!({
253298
executionId,
@@ -263,22 +308,49 @@ export class DroidAgentPlugin extends BaseAgentPlugin {
263308
});
264309
};
265310

311+
const scheduleDrainFallback = (): void => {
312+
if (completed) {
313+
return;
314+
}
315+
clearDrainFallback();
316+
const remainingBudget = (exitAt ?? Date.now()) + STDIO_DRAIN_MAX_WAIT_MS - Date.now();
317+
const delay = Math.min(STDIO_DRAIN_GRACE_PERIOD_MS, remainingBudget);
318+
const finalize = (): void => {
319+
drainFallbackId = undefined;
320+
if (completed) {
321+
return;
322+
}
323+
if (process.env.RALPH_DEBUG) {
324+
debugLog(
325+
`[DEBUG] Process stdio stayed open after drain: code=${exitCode}, ` +
326+
`signal=${exitSignal}, execId=${executionId}`
327+
);
328+
}
329+
complete(deriveExitStatus(exitCode, exitSignal), exitCode ?? undefined);
330+
proc.stdout?.destroy();
331+
proc.stderr?.destroy();
332+
};
333+
334+
if (delay <= 0) {
335+
finalize();
336+
return;
337+
}
338+
drainFallbackId = setTimeout(finalize, delay);
339+
};
340+
266341
proc.on('error', (error) => {
267342
complete('failed', undefined, error.message);
268343
});
269344

270345
proc.on('close', (code, signal) => {
271-
let status: AgentExecutionStatus;
272-
if (interrupted) {
273-
status = 'interrupted';
274-
} else if (signal === 'SIGTERM' || signal === 'SIGKILL') {
275-
status = timeoutId ? 'timeout' : 'interrupted';
276-
} else if (code === 0) {
277-
status = 'completed';
278-
} else {
279-
status = 'failed';
280-
}
281-
complete(status, code ?? undefined);
346+
complete(deriveExitStatus(code, signal), code ?? undefined);
347+
});
348+
349+
proc.on('exit', (code, signal) => {
350+
exitCode = code;
351+
exitSignal = signal;
352+
exitAt = Date.now();
353+
scheduleDrainFallback();
282354
});
283355

284356
if (timeout > 0) {

0 commit comments

Comments
 (0)