Skip to content

Commit 8f14577

Browse files
committed
fix: kill entire process group on agent close to prevent orphan child processes
kiro-cli is a wrapper that forks kiro-cli-chat as the actual ACP server. Sending SIGTERM to the wrapper (kiro-cli) does not kill the child process (kiro-cli-chat), which becomes an orphan and accumulates over time. Fix: spawn the agent with detached:true so it becomes a process group leader, then use process.kill(-pgid, SIGTERM) to kill the entire process group including all child processes. Also adds onAgentPid callback to runQueuedTask/runSessionPrompt to allow callers to track the agent pid for cleanup purposes. Fixes: kiro-cli-chat acp process leak on /new
1 parent ef1fd90 commit 8f14577

2 files changed

Lines changed: 35 additions & 4 deletions

File tree

src/client.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ export class AcpClient {
372372
cwd: this.options.cwd,
373373
env: buildAgentEnvironment(this.options.authCredentials),
374374
stdio: ["pipe", "pipe", "pipe"],
375+
detached: true,
375376
});
376377

377378
try {
@@ -718,10 +719,14 @@ export class AcpClient {
718719

719720
const agent = this.agent;
720721
if (agent) {
721-
// Some adapters keep stdio handles alive after SIGTERM; explicitly
722-
// destroy/unref so CLI calls can exit deterministically.
722+
// Kill the entire process group to ensure child processes (e.g. kiro-cli-chat)
723+
// are also terminated when the parent wrapper exits.
723724
if (!agent.killed) {
724-
agent.kill();
725+
try {
726+
process.kill(-agent.pid!, "SIGTERM");
727+
} catch {
728+
agent.kill();
729+
}
725730
}
726731
this.detachAgentHandles(agent);
727732
}

src/session-runtime.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ type RunSessionPromptOptions = {
201201
onClientAvailable?: (controller: ActiveSessionController) => void;
202202
onClientClosed?: () => void;
203203
onPromptActive?: () => Promise<void> | void;
204+
onAgentPid?: (pid: number) => void;
204205
};
205206

206207
type ActiveSessionController = QueueOwnerActiveSessionController;
@@ -291,6 +292,7 @@ async function runQueuedTask(
291292
onClientAvailable?: (controller: ActiveSessionController) => void;
292293
onClientClosed?: () => void;
293294
onPromptActive?: () => Promise<void> | void;
295+
onAgentPid?: (pid: number) => void;
294296
},
295297
): Promise<void> {
296298
const outputFormatter = task.waitForCompletion
@@ -314,6 +316,7 @@ async function runQueuedTask(
314316
onClientAvailable: options.onClientAvailable,
315317
onClientClosed: options.onClientClosed,
316318
onPromptActive: options.onPromptActive,
319+
onAgentPid: options.onAgentPid,
317320
});
318321

319322
if (task.waitForCompletion) {
@@ -448,6 +451,9 @@ async function runSessionPrompt(
448451
},
449452
onConnectedRecord: (connectedRecord) => {
450453
connectedRecord.lastPromptAt = isoNow();
454+
if (connectedRecord.pid != null) {
455+
options.onAgentPid?.(connectedRecord.pid);
456+
}
451457
},
452458
onSessionIdResolved: (sessionId) => {
453459
activeSessionIdForControl = sessionId;
@@ -745,6 +751,7 @@ export async function runSessionQueueOwner(
745751
}
746752

747753
let owner: SessionQueueOwner | undefined;
754+
let lastAgentPid: number | undefined;
748755
const ttlMs = normalizeQueueOwnerTtlMs(options.ttlMs);
749756
const taskPollTimeoutMs = ttlMs === 0 ? undefined : ttlMs;
750757
const initialTaskPollTimeoutMs =
@@ -857,13 +864,23 @@ export async function runSessionQueueOwner(
857864
authCredentials: options.authCredentials,
858865
authPolicy: options.authPolicy,
859866
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
860-
onClientAvailable: setActiveController,
867+
onClientAvailable: (controller) => {
868+
setActiveController(controller);
869+
},
861870
onClientClosed: clearActiveController,
862871
onPromptActive: async () => {
863872
turnController.markPromptActive();
864873
await applyPendingCancel();
865874
},
875+
onAgentPid: (pid) => {
876+
lastAgentPid = pid;
877+
},
866878
});
879+
// Track the agent pid after each task so we can kill it when the queue owner exits
880+
const record = await resolveSessionRecord(options.sessionId).catch(() => null);
881+
if (record?.pid != null) {
882+
lastAgentPid = record.pid;
883+
}
867884
});
868885
}
869886
} finally {
@@ -872,6 +889,15 @@ export async function runSessionQueueOwner(
872889
await owner.close();
873890
}
874891
await releaseQueueOwnerLease(lease);
892+
// Kill the agent process if it is still alive (e.g. kiro-cli which does not self-exit)
893+
if (lastAgentPid != null && isProcessAlive(lastAgentPid)) {
894+
await terminateProcess(lastAgentPid).catch(() => {});
895+
if (options.verbose) {
896+
process.stderr.write(
897+
`[acpx] killed agent pid ${lastAgentPid} on queue owner exit for session ${options.sessionId}\n`,
898+
);
899+
}
900+
}
875901
if (options.verbose) {
876902
process.stderr.write(
877903
`[acpx] queue owner stopped for session ${options.sessionId}\n`,

0 commit comments

Comments
 (0)