diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6909363..7d2af2a 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { prompt, FileStorage, convertToATIF } from 'open-agent-sdk'; +import { prompt, FileStorage, convertToATIF, cleanupBackgroundProcesses } from 'open-agent-sdk'; const args = process.argv.slice(2); @@ -48,6 +48,7 @@ async function main() { ? undefined : new FileStorage(sessionDir ? { directory: sessionDir } : {}); + let exitCode = 0; try { const result = await prompt(instruction!, { model: model!, @@ -88,7 +89,11 @@ async function main() { } else { console.error(String(err)); } - process.exit(1); + exitCode = 1; + } finally { + // Best-effort cleanup to avoid background command handles blocking process exit. + await cleanupBackgroundProcesses(); + if (exitCode !== 0) process.exit(exitCode); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2141565..5fecc71 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -336,6 +336,7 @@ export { editTool, BashTool, bashTool, + cleanupBackgroundProcesses, GlobTool, globTool, GrepTool, diff --git a/packages/core/src/tools/bash.ts b/packages/core/src/tools/bash.ts index 26e11cc..fa73a2e 100644 --- a/packages/core/src/tools/bash.ts +++ b/packages/core/src/tools/bash.ts @@ -159,6 +159,11 @@ export class BashTool implements Tool { bgProcess.exitCode = code ?? -1; }); + // Prevent background child handles from keeping the process alive. + child.unref(); + (child.stdout as unknown as { unref?: () => void } | null)?.unref?.(); + (child.stderr as unknown as { unref?: () => void } | null)?.unref?.(); + // Don't wait for completion resolve({ output: `Command running in background with ID: ${shellId}`, @@ -258,3 +263,47 @@ export const bashTool = new BashTool(); export function getBackgroundProcess(shellId: string) { return backgroundProcesses.get(shellId); } + +/** + * Best-effort cleanup for any running background bash processes. + * Intended for process shutdown paths (e.g. CLI exit) to avoid hangs. + */ +export async function cleanupBackgroundProcesses( + forceKillAfterMs = 1000 +): Promise { + const entries = Array.from(backgroundProcesses.values()).filter((p) => p.exitCode === null); + + await Promise.all( + entries.map( + (bgProcess) => + new Promise((resolve) => { + if (bgProcess.exitCode !== null) { + resolve(); + return; + } + + let settled = false; + const done = () => { + if (settled) return; + settled = true; + clearTimeout(forceKillTimer); + resolve(); + }; + + bgProcess.process.once('exit', (code) => { + bgProcess.exitCode = code ?? -1; + done(); + }); + + bgProcess.process.kill('SIGTERM'); + + const forceKillTimer = setTimeout(() => { + if (bgProcess.exitCode === null) { + bgProcess.process.kill('SIGKILL'); + } + done(); + }, Math.max(1, forceKillAfterMs)); + }) + ) + ); +} diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index e659117..a7acc02 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -91,7 +91,7 @@ export const defaultToolRegistry = createDefaultRegistry(); export { readTool, ReadTool } from './read'; export { writeTool, WriteTool } from './write'; export { editTool, EditTool } from './edit'; -export { bashTool, BashTool } from './bash'; +export { bashTool, BashTool, cleanupBackgroundProcesses } from './bash'; export { globTool, GlobTool } from './glob'; export { grepTool, GrepTool } from './grep'; export { taskListTool, TaskListTool } from './task-list'; diff --git a/packages/core/tests/tools/bash-enhanced.test.ts b/packages/core/tests/tools/bash-enhanced.test.ts index 698945c..7fd7d24 100644 --- a/packages/core/tests/tools/bash-enhanced.test.ts +++ b/packages/core/tests/tools/bash-enhanced.test.ts @@ -3,7 +3,12 @@ */ import { describe, test, expect, beforeEach } from 'bun:test'; -import { BashTool, getBackgroundProcess } from '../../src/tools/bash'; +import { + BashTool, + getBackgroundProcess, + backgroundProcesses, + cleanupBackgroundProcesses, +} from '../../src/tools/bash'; import type { ToolContext } from '../../src/types/tools'; describe('BashTool - Enhanced Background Process Tracking', () => { @@ -11,6 +16,7 @@ describe('BashTool - Enhanced Background Process Tracking', () => { let context: ToolContext; beforeEach(() => { + backgroundProcesses.clear(); tool = new BashTool(); context = { cwd: process.cwd(), @@ -142,4 +148,23 @@ describe('BashTool - Enhanced Background Process Tracking', () => { expect(process?.process).toBeDefined(); expect(process?.process.pid).toBe(process?.pid); }); + + test('should cleanup running background processes', async () => { + const result = await tool.handler( + { + command: 'sleep 10', + run_in_background: true, + }, + context + ); + + const shellId = result.shellId!; + const processBeforeCleanup = getBackgroundProcess(shellId); + expect(processBeforeCleanup?.exitCode).toBeNull(); + + await cleanupBackgroundProcesses(200); + + const processAfterCleanup = getBackgroundProcess(shellId); + expect(processAfterCleanup?.exitCode).not.toBeNull(); + }); });