From 3dc17b7f304e9392269c5353395df4d0da143c9e Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:10:44 +0200 Subject: [PATCH] fix(acp): keep the daemon running when an ACP agent cannot start (#5439) Opening an archived ACP agent whose worktree was removed spawned the provider in the missing directory. The session spawn had no listener for the child's error event, so Node's ENOENT became an uncaught exception and the daemon restarted, then crashed again when the client retried. The session spawn now rejects initialization on a spawn error, the same way the provider probe already did, and both share one helper. Closes #5435 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: qinkangdeid --- .../server/agent/providers/acp-agent.test.ts | 31 +++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 38 ++++++++++++------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 07d65a8c86..9d106a2c1a 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,5 +1,8 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import { AgentSideConnection, @@ -3521,6 +3524,34 @@ describe("ACPAgentSession close() tree-kill", () => { }); describe("ACPAgentSession initialization cleanup", () => { + test("rejects a resume whose working directory was deleted instead of crashing", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-deleted-cwd-")); + const deletedCwd = path.join(root, "worktree"); + const terminator = new FakeTerminator(); + const session = new ACPAgentSession( + { provider: "test-acp", cwd: deletedCwd }, + { + provider: "test-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, "-e", "process.stdin.resume()"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + }, + handle: { provider: "test-acp", sessionId: "archived-session" }, + terminateProcess: terminator.terminate, + }, + ); + + try { + await expect(session.initializeResumedSession()).rejects.toThrow("ENOENT"); + expect(terminator.terminated).toHaveLength(1); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test("terminates the ACP process when session/new fails", async () => { const terminator = new FakeTerminator(); const child = createProbeChildStub(); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 902b0f770e..d2e0a1beb6 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -136,6 +136,17 @@ function assertChildWithPipes( } } +// Node reports a failed spawn (missing binary or missing cwd) as a child +// `error` event; without a listener it becomes an uncaught exception. +function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise { + return new Promise((_, reject) => { + child.once("error", (error) => { + const stderr = stderrChunks.join("").trim(); + reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); + }); + }); +} + function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -1354,12 +1365,7 @@ export class ACPAgentClient implements AgentClient { stderrChunks.push(chunk.toString()); }); - const spawnErrorPromise = new Promise((_, reject) => { - child.once("error", (error) => { - const stderr = stderrChunks.join("").trim(); - reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); - }); - }); + const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks); const spawnReadyPromise = new Promise((resolve) => { child.once("spawn", () => { resolve(); @@ -2732,6 +2738,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); }); + const spawnError = rejectOnSpawnError(child, stderrChunks); child.once("exit", (code, signal) => { if (this.closed) { return; @@ -2759,14 +2766,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.child = child; this.connection = connection; const initialize = await this.runACPRequest(() => - connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: buildACPClientCapabilities( - this.clientCapabilityMeta, - this.clientCapabilities, - ), - clientInfo: { name: "Paseo", version: "dev" }, - }), + Promise.race([ + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: buildACPClientCapabilities( + this.clientCapabilityMeta, + this.clientCapabilities, + ), + clientInfo: { name: "Paseo", version: "dev" }, + }), + spawnError, + ]), ); return { child, connection, initialize };