mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
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:
co-authored by
qinkangdeid
parent
76a9781ba5
commit
3dc17b7f30
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user