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 };