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 <qinkangdeidd@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-09-26 15:10:44 +02:00
committed by GitHub
co-authored by qinkangdeid
parent 76a9781ba5
commit 3dc17b7f30
2 changed files with 55 additions and 14 deletions
@@ -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();
@@ -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<never> {
return new Promise<never>((_, 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<string, unknown> {
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<never>((_, 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<void>((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 };