Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -48,6 +48,7 @@ async function main() {
? undefined
: new FileStorage(sessionDir ? { directory: sessionDir } : {});

let exitCode = 0;
try {
const result = await prompt(instruction!, {
model: model!,
Expand Down Expand Up @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ export {
editTool,
BashTool,
bashTool,
cleanupBackgroundProcesses,
GlobTool,
globTool,
GrepTool,
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ export class BashTool implements Tool<BashInput, BashOutput> {
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}`,
Expand Down Expand Up @@ -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<void> {
const entries = Array.from(backgroundProcesses.values()).filter((p) => p.exitCode === null);

await Promise.all(
entries.map(
(bgProcess) =>
new Promise<void>((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));
})
)
);
}
2 changes: 1 addition & 1 deletion packages/core/src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
27 changes: 26 additions & 1 deletion packages/core/tests/tools/bash-enhanced.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
*/

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', () => {
let tool: BashTool;
let context: ToolContext;

beforeEach(() => {
backgroundProcesses.clear();
tool = new BashTool();
context = {
cwd: process.cwd(),
Expand Down Expand Up @@ -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();
});
});