Skip to content

Commit ad7f10e

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 b886484 commit ad7f10e

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
@@ -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;
@@ -422,7 +422,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
422422
taskId,
423423
repoPath,
424424
agent,
425-
connection,
425+
clientSideConnection: connection,
426426
channel,
427427
createdAt: Date.now(),
428428
lastActivityAt: Date.now(),
@@ -438,6 +438,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
438438
}
439439
return session;
440440
} catch (err) {
441+
try {
442+
await agent.cleanup();
443+
} catch {
444+
log.debug("Agent cleanup failed during error handling", { taskRunId });
445+
}
441446
this.cleanupMockNodeEnvironment(mockNodeDir);
442447
if (!isRetry && isAuthError(err)) {
443448
log.warn(
@@ -518,7 +523,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
518523
session.promptPending = true;
519524

520525
try {
521-
const result = await session.connection.prompt({
526+
const result = await session.clientSideConnection.prompt({
522527
sessionId,
523528
prompt: finalPrompt,
524529
});
@@ -530,7 +535,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
530535
if (isAuthError(err)) {
531536
log.warn("Auth error during prompt, recreating session", { sessionId });
532537
session = await this.recreateSession(sessionId);
533-
const result = await session.connection.prompt({
538+
const result = await session.clientSideConnection.prompt({
534539
sessionId,
535540
prompt: finalPrompt,
536541
});
@@ -565,7 +570,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
565570
if (!session) return false;
566571

567572
try {
568-
await session.connection.cancel({
573+
await session.clientSideConnection.cancel({
569574
sessionId,
570575
_meta: reason ? { interruptReason: reason } : undefined,
571576
});
@@ -591,7 +596,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
591596
}
592597

593598
try {
594-
await session.connection.extMethod("session/setModel", {
599+
await session.clientSideConnection.extMethod("session/setModel", {
595600
sessionId,
596601
modelId,
597602
});
@@ -609,7 +614,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
609614
}
610615

611616
try {
612-
await session.connection.extMethod("session/setMode", {
617+
await session.clientSideConnection.extMethod("session/setMode", {
613618
sessionId,
614619
modeId,
615620
});
@@ -722,32 +727,15 @@ For git operations while detached:
722727
}
723728

724729
async cleanupAll(): Promise<void> {
730+
const sessionIds = Array.from(this.sessions.keys());
725731
log.info("Cleaning up all agent sessions", {
726-
sessionCount: this.sessions.size,
732+
sessionCount: sessionIds.length,
727733
});
728734

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

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

@@ -807,22 +795,11 @@ For git operations while detached:
807795
private async cleanupSession(taskRunId: string): Promise<void> {
808796
const session = this.sessions.get(taskRunId);
809797
if (session) {
810-
// Cancel any ongoing operations
811-
try {
812-
if (!session.connection.signal.aborted) {
813-
await session.connection.cancel({ sessionId: taskRunId });
814-
}
815-
} catch {
816-
// Ignore cancel errors
817-
}
818-
819-
// Cleanup agent (closes streams, aborts subprocess)
820798
try {
821799
await session.agent.cleanup();
822800
} catch {
823-
// Ignore cleanup errors
801+
log.debug("Agent cleanup failed", { taskRunId });
824802
}
825-
826803
this.cleanupMockNodeEnvironment(session.mockNodeDir);
827804
this.sessions.delete(taskRunId);
828805
}

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)