Skip to content

Commit b49ae18

Browse files
committed
fix: properly clean up processes on every code path
When sessions were recreated, e.g. after token refresh, old agent processes were not properly being cleaned up. This (probably) causes zombie processes such as in #523.
1 parent 4f46438 commit b49ae18

File tree

4 files changed

+37
-47
lines changed

4 files changed

+37
-47
lines changed

apps/twig/src/main/services/agent/service.ts

Lines changed: 17 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ interface ManagedSession {
142142
taskId: string;
143143
repoPath: string;
144144
agent: Agent;
145-
connection: ClientSideConnection;
145+
clientSideConnection: ClientSideConnection;
146146
channel: string;
147147
createdAt: number;
148148
lastActivityAt: number;
@@ -421,7 +421,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
421421
taskId,
422422
repoPath,
423423
agent,
424-
connection,
424+
clientSideConnection: connection,
425425
channel,
426426
createdAt: Date.now(),
427427
lastActivityAt: Date.now(),
@@ -437,6 +437,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
437437
}
438438
return session;
439439
} catch (err) {
440+
try {
441+
await agent.cleanup();
442+
} catch {
443+
log.debug("Agent cleanup failed during error handling", { taskRunId });
444+
}
440445
this.cleanupMockNodeEnvironment(mockNodeDir);
441446
if (!isRetry && isAuthError(err)) {
442447
log.warn(
@@ -517,7 +522,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
517522
session.promptPending = true;
518523

519524
try {
520-
const result = await session.connection.prompt({
525+
const result = await session.clientSideConnection.prompt({
521526
sessionId,
522527
prompt: finalPrompt,
523528
});
@@ -529,7 +534,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
529534
if (isAuthError(err)) {
530535
log.warn("Auth error during prompt, recreating session", { sessionId });
531536
session = await this.recreateSession(sessionId);
532-
const result = await session.connection.prompt({
537+
const result = await session.clientSideConnection.prompt({
533538
sessionId,
534539
prompt: finalPrompt,
535540
});
@@ -564,7 +569,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
564569
if (!session) return false;
565570

566571
try {
567-
await session.connection.cancel({
572+
await session.clientSideConnection.cancel({
568573
sessionId,
569574
_meta: reason ? { interruptReason: reason } : undefined,
570575
});
@@ -590,7 +595,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
590595
}
591596

592597
try {
593-
await session.connection.extMethod("session/setModel", {
598+
await session.clientSideConnection.extMethod("session/setModel", {
594599
sessionId,
595600
modelId,
596601
});
@@ -608,7 +613,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
608613
}
609614

610615
try {
611-
await session.connection.extMethod("session/setMode", {
616+
await session.clientSideConnection.extMethod("session/setMode", {
612617
sessionId,
613618
modeId,
614619
});
@@ -721,32 +726,15 @@ For git operations while detached:
721726
}
722727

723728
async cleanupAll(): Promise<void> {
729+
const sessionIds = Array.from(this.sessions.keys());
724730
log.info("Cleaning up all agent sessions", {
725-
sessionCount: this.sessions.size,
731+
sessionCount: sessionIds.length,
726732
});
727733

728-
for (const [taskRunId, session] of this.sessions) {
729-
// Step 1: Send ACP cancel notification for any ongoing prompt turns
730-
try {
731-
if (!session.connection.signal.aborted) {
732-
await session.connection.cancel({ sessionId: taskRunId });
733-
log.info("Sent ACP cancel for session", { taskRunId });
734-
}
735-
} catch (err) {
736-
log.warn("Failed to send ACP cancel", { taskRunId, error: err });
737-
}
738-
739-
// Step 2: Cleanup agent connection (closes streams, aborts subprocess)
740-
try {
741-
await session.agent.cleanup();
742-
} catch (err) {
743-
log.warn("Failed to cleanup agent", { taskRunId, error: err });
744-
}
745-
746-
this.cleanupMockNodeEnvironment(session.mockNodeDir);
734+
for (const taskRunId of sessionIds) {
735+
await this.cleanupSession(taskRunId);
747736
}
748737

749-
this.sessions.clear();
750738
log.info("All agent sessions cleaned up");
751739
}
752740

@@ -806,22 +794,11 @@ For git operations while detached:
806794
private async cleanupSession(taskRunId: string): Promise<void> {
807795
const session = this.sessions.get(taskRunId);
808796
if (session) {
809-
// Cancel any ongoing operations
810-
try {
811-
if (!session.connection.signal.aborted) {
812-
await session.connection.cancel({ sessionId: taskRunId });
813-
}
814-
} catch {
815-
// Ignore cancel errors
816-
}
817-
818-
// Cleanup agent (closes streams, aborts subprocess)
819797
try {
820798
await session.agent.cleanup();
821799
} catch {
822-
// Ignore cleanup errors
800+
log.debug("Agent cleanup failed", { taskRunId });
823801
}
824-
825802
this.cleanupMockNodeEnvironment(session.mockNodeDir);
826803
this.sessions.delete(taskRunId);
827804
}

packages/agent/src/adapters/acp-connection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function createAcpConnection(
9292
logger.info("Cleaning up ACP connection");
9393

9494
if (agent) {
95-
agent.closeAllSessions();
95+
await agent.closeAllSessions();
9696
}
9797

9898
// Then close the streams to properly terminate the ACP connection

packages/agent/src/adapters/base-acp-agent.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import type {
2+
Agent,
23
AgentSideConnection,
34
AuthenticateRequest,
5+
CancelNotification,
6+
InitializeRequest,
7+
InitializeResponse,
8+
NewSessionRequest,
9+
NewSessionResponse,
10+
PromptRequest,
11+
PromptResponse,
412
ReadTextFileRequest,
513
ReadTextFileResponse,
614
SessionNotification,
@@ -15,7 +23,7 @@ export interface BaseSession extends SessionState {
1523
interruptReason?: string;
1624
}
1725

18-
export abstract class BaseAcpAgent {
26+
export abstract class BaseAcpAgent implements Agent {
1927
abstract readonly adapterName: string;
2028
protected sessions: { [key: string]: BaseSession } = {};
2129
client: AgentSideConnection;
@@ -27,13 +35,19 @@ export abstract class BaseAcpAgent {
2735
this.logger = new Logger({ debug: true, prefix: "[BaseAcpAgent]" });
2836
}
2937

30-
closeAllSessions(): void {
38+
abstract initialize(request: InitializeRequest): Promise<InitializeResponse>;
39+
abstract newSession(params: NewSessionRequest): Promise<NewSessionResponse>;
40+
abstract prompt(params: PromptRequest): Promise<PromptResponse>;
41+
abstract cancel(params: CancelNotification): Promise<void>;
42+
43+
async closeAllSessions(): Promise<void> {
3144
for (const [sessionId, session] of Object.entries(this.sessions)) {
3245
try {
46+
await this.cancel({ sessionId });
3347
session.abortController.abort();
34-
this.logger.info("Aborted session", { sessionId });
48+
this.logger.info("Closed session", { sessionId });
3549
} catch (err) {
36-
this.logger.warn("Failed to abort session", { sessionId, error: err });
50+
this.logger.warn("Failed to close session", { sessionId, error: err });
3751
}
3852
}
3953
this.sessions = {};

packages/agent/src/adapters/claude/agent.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as fs from "node:fs";
22
import * as os from "node:os";
33
import * as path from "node:path";
44
import {
5-
type Agent,
65
type AgentSideConnection,
76
type AuthenticateRequest,
87
type AvailableCommand,
@@ -146,7 +145,7 @@ async function getAvailableSlashCommands(
146145
);
147146
}
148147

149-
export class ClaudeAcpAgent extends BaseAcpAgent implements Agent {
148+
export class ClaudeAcpAgent extends BaseAcpAgent {
150149
readonly adapterName = "claude";
151150
declare sessions: { [key: string]: Session };
152151
toolUseCache: ToolUseCache;

0 commit comments

Comments
 (0)