Skip to content

Commit 02e768f

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 eb679c4 commit 02e768f

4 files changed

Lines changed: 37 additions & 47 deletions

File tree

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

Lines changed: 17 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ interface ManagedSession {
169169
taskId: string;
170170
repoPath: string;
171171
agent: Agent;
172-
connection: ClientSideConnection;
172+
clientSideConnection: ClientSideConnection;
173173
channel: string;
174174
createdAt: number;
175175
lastActivityAt: number;
@@ -449,7 +449,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
449449
taskId,
450450
repoPath,
451451
agent,
452-
connection,
452+
clientSideConnection: connection,
453453
channel,
454454
createdAt: Date.now(),
455455
lastActivityAt: Date.now(),
@@ -465,6 +465,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
465465
}
466466
return session;
467467
} catch (err) {
468+
try {
469+
await agent.cleanup();
470+
} catch {
471+
log.debug("Agent cleanup failed during error handling", { taskRunId });
472+
}
468473
this.cleanupMockNodeEnvironment(mockNodeDir);
469474
if (!isRetry && isAuthError(err)) {
470475
log.warn(
@@ -545,7 +550,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
545550
session.promptPending = true;
546551

547552
try {
548-
const result = await session.connection.prompt({
553+
const result = await session.clientSideConnection.prompt({
549554
sessionId,
550555
prompt: finalPrompt,
551556
});
@@ -557,7 +562,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
557562
if (isAuthError(err)) {
558563
log.warn("Auth error during prompt, recreating session", { sessionId });
559564
session = await this.recreateSession(sessionId);
560-
const result = await session.connection.prompt({
565+
const result = await session.clientSideConnection.prompt({
561566
sessionId,
562567
prompt: finalPrompt,
563568
});
@@ -592,7 +597,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
592597
if (!session) return false;
593598

594599
try {
595-
await session.connection.cancel({
600+
await session.clientSideConnection.cancel({
596601
sessionId,
597602
_meta: reason ? { interruptReason: reason } : undefined,
598603
});
@@ -618,7 +623,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
618623
}
619624

620625
try {
621-
await session.connection.extMethod("session/setModel", {
626+
await session.clientSideConnection.extMethod("session/setModel", {
622627
sessionId,
623628
modelId,
624629
});
@@ -636,7 +641,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
636641
}
637642

638643
try {
639-
await session.connection.extMethod("session/setMode", {
644+
await session.clientSideConnection.extMethod("session/setMode", {
640645
sessionId,
641646
modeId,
642647
});
@@ -750,32 +755,15 @@ For git operations while detached:
750755

751756
@preDestroy()
752757
async cleanupAll(): Promise<void> {
758+
const sessionIds = Array.from(this.sessions.keys());
753759
log.info("Cleaning up all agent sessions", {
754-
sessionCount: this.sessions.size,
760+
sessionCount: sessionIds.length,
755761
});
756762

757-
for (const [taskRunId, session] of this.sessions) {
758-
// Step 1: Send ACP cancel notification for any ongoing prompt turns
759-
try {
760-
if (!session.connection.signal.aborted) {
761-
await session.connection.cancel({ sessionId: taskRunId });
762-
log.info("Sent ACP cancel for session", { taskRunId });
763-
}
764-
} catch (err) {
765-
log.warn("Failed to send ACP cancel", { taskRunId, error: err });
766-
}
767-
768-
// Step 2: Cleanup agent connection (closes streams, aborts subprocess)
769-
try {
770-
await session.agent.cleanup();
771-
} catch (err) {
772-
log.warn("Failed to cleanup agent", { taskRunId, error: err });
773-
}
774-
775-
this.cleanupMockNodeEnvironment(session.mockNodeDir);
763+
for (const taskRunId of sessionIds) {
764+
await this.cleanupSession(taskRunId);
776765
}
777766

778-
this.sessions.clear();
779767
log.info("All agent sessions cleaned up");
780768
}
781769

@@ -835,22 +823,11 @@ For git operations while detached:
835823
private async cleanupSession(taskRunId: string): Promise<void> {
836824
const session = this.sessions.get(taskRunId);
837825
if (session) {
838-
// Cancel any ongoing operations
839-
try {
840-
if (!session.connection.signal.aborted) {
841-
await session.connection.cancel({ sessionId: taskRunId });
842-
}
843-
} catch {
844-
// Ignore cancel errors
845-
}
846-
847-
// Cleanup agent (closes streams, aborts subprocess)
848826
try {
849827
await session.agent.cleanup();
850828
} catch {
851-
// Ignore cleanup errors
829+
log.debug("Agent cleanup failed", { taskRunId });
852830
}
853-
854831
this.cleanupMockNodeEnvironment(session.mockNodeDir);
855832
this.sessions.delete(taskRunId);
856833
}

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)