diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index f3a23fc..c1181ba 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -391,6 +391,42 @@ export const ensureUserContainer = async (args: { return name; }; +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +// Wraps a CLI argv so the process runs as a new session/process-group leader whose +// bash parent carries the marker in its argv (matchable by `pgrep -f`). We do NOT +// `exec` (bash must survive so the marker stays visible); stdout/stderr fds are +// inherited by the CLI so streaming still works. +export const buildMarkedCommand = ( + marker: string, + command: string[], +): string[] => { + const script = `# ${marker}\nexec 0 + [ + `pids=$(pgrep -f ${shellQuote(marker)} || true)`, + `for pid in $pids; do kill -TERM -"$pid" 2>/dev/null || true; done`, + `sleep 2`, + `for pid in $pids; do kill -KILL -"$pid" 2>/dev/null || true; done`, + ].join('\n'); + +export const killBoxProcessesByMarker = async (args: { + containerName: string; + marker: string; +}): Promise => { + await execa( + containerRuntime(), + ['exec', args.containerName, 'bash', '-lc', buildKillScript(args.marker)], + { reject: false, stdin: 'ignore' }, + ); +}; + export const streamExecInContainer = async (args: { containerName: string; command: string[]; diff --git a/apps/agent-worker/tests/unit/box-process-kill.test.ts b/apps/agent-worker/tests/unit/box-process-kill.test.ts new file mode 100644 index 0000000..573e1d1 --- /dev/null +++ b/apps/agent-worker/tests/unit/box-process-kill.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; + +const loadDocker = async () => { + process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; + process.env.GITHUB_APP_ID = '123'; + process.env.GITHUB_APP_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----'; + return await import('../../src/runtime/docker'); +}; + +describe('in-box process-group marker + kill helpers', () => { + test('buildMarkedCommand wraps argv as a setsid bash session carrying the marker', async () => { + const { buildMarkedCommand } = await loadDocker(); + const argv = buildMarkedCommand('spoon-turn-abc', [ + 'codex', + 'exec', + '--json', + 'hi', + ]); + expect(argv[0]).toBe('setsid'); + expect(argv[1]).toBe('bash'); + expect(argv[2]).toBe('-lc'); + const script = argv[3] ?? ''; + expect(script).toContain('# spoon-turn-abc'); + expect(script.split('\n').at(-1)).toBe("'codex' 'exec' '--json' 'hi'"); + }); + + test('buildKillScript TERM-then-KILLs every group matching the marker', async () => { + const { buildKillScript } = await loadDocker(); + const script = buildKillScript('spoon-turn-abc'); + expect(script).toContain("pgrep -f 'spoon-turn-abc'"); + expect(script).toContain('kill -TERM -"$pid"'); + expect(script).toContain('kill -KILL -"$pid"'); + }); +});