Skip to content

Commit d07fc56

Browse files
authored
fix: properly clean up processes on every code path (#580)
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 9ed6e38 commit d07fc56

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)