fix(acp): report provider startup exits without waiting for timeout (#8384)

* fix(server): resolve ACP providers installed after the daemon started

Windows hands a new process the PATH its parent had when it started. The
daemon's supervisor therefore keeps the PATH it inherited at launch, and any
CLI installed afterwards stays invisible to it. A provider installed after the
daemon started reported "not installed" forever, even though the binary was
on PATH in a fresh shell.

Refresh the process PATH from the Windows user/machine registry environment
when the daemon worker starts. Existing entries keep their order and take
precedence; registry entries are appended. This runs once at startup so
PATH-isolated availability checks and explicit env overrides stay
authoritative.

Spawn the absolute path the launcher resolver verified rather than the bare
command. The resolver already finds installs outside the daemon's inherited
PATH (Winget packages, directories added after startup); the resolved path
was previously discarded.

An ACP agent that exits during the handshake never rejects the SDK's
initialize request, so the provider refresh waited out its full 120s deadline
and reported "Timed out refreshing <provider> after 120000ms; pending:
initialize" instead of the child's stderr. Race a child-exit rejection
against initialize so the real failure surfaces.

Also repair persisted MiniMax Code commands. The first catalog entry ran
`npx -y @minimax-ai/code@<version> acp`; npx derives the executable name from
the package name (`code`), but the package publishes `mcode`, so npm exited
before the ACP handshake. The catalog was fixed separately, but commands
already persisted by an older client were never migrated.

* fix(acp): report startup exits across probes and sessions

Extend the shared process failure handler so initialize rejects with stderr after the child exits, including session creation. Keep this fix scoped to startup error reporting; remove the separate PATH and persisted-command changes.

Co-authored-by: peanut_hzm <peanut_hzm@163.com>

* fix(acp): validate provider options before starting probes

Reject malformed options before creating a child or its process-failure promise, so cleanup cannot cause an unhandled rejection in the daemon.

---------

Co-authored-by: peanut_hzm <peanut_hzm@163.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-31 14:55:17 +00:00
committed by GitHub
co-authored by peanut_hzm
parent b117e3fb0f
commit e02fa9a5f8
2 changed files with 138 additions and 14 deletions
@@ -142,14 +142,26 @@ 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> {
// Watch process failures as well as spawn errors: the SDK leaves initialize
// pending when the child exits without replying. `close` waits for stderr to
// drain, so the failure includes the launcher's diagnostic.
function rejectOnProcessFailure(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)));
});
child.once("close", (code, signal) => {
const stderr = stderrChunks.join("").trim();
const status = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(
new Error(
`ACP agent process exited before initialization completed (${status})${
stderr ? `\n${stderr}` : ""
}`,
),
);
});
});
}
@@ -542,7 +554,7 @@ interface ACPProcessTransport {
connection: ClientSideConnection;
stderrChunks: string[];
spawnReady: Promise<void>;
spawnError: Promise<never>;
processError: Promise<never>;
}
interface ACPImportPromptPreviews {
@@ -1380,6 +1392,7 @@ export class ACPAgentClient implements AgentClient {
client?: ACPClient;
},
): Promise<SpawnedACPProcess> {
const configuredOptions = ACPProviderOptionsSchema.parse(options?.providerOptions ?? {});
const transport = await this.spawnTransport(launchEnv, options?.client);
const probe: UninitializedACPProcess = {
child: transport.child,
@@ -1391,7 +1404,7 @@ export class ACPAgentClient implements AgentClient {
const initialize = await this.initializeTransport(
transport,
options?.initializeTimeoutMs,
options?.providerOptions,
configuredOptions.clientCapabilities,
);
const initializedProbe: SpawnedACPProcess = {
...probe,
@@ -1425,7 +1438,7 @@ export class ACPAgentClient implements AgentClient {
stderrChunks.push(chunk.toString());
});
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
const processErrorPromise = rejectOnProcessFailure(child, stderrChunks);
const spawnReadyPromise = new Promise<void>((resolve) => {
child.once("spawn", () => {
resolve();
@@ -1444,16 +1457,15 @@ export class ACPAgentClient implements AgentClient {
connection,
stderrChunks,
spawnReady: spawnReadyPromise,
spawnError: spawnErrorPromise,
processError: processErrorPromise,
};
}
protected async initializeTransport(
transport: ACPProcessTransport,
initializeTimeoutMs?: number,
providerOptions?: Record<string, unknown>,
clientCapabilities?: ACPClientCapabilities,
): Promise<InitializeResponse> {
const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {});
let timeout: ReturnType<typeof setTimeout> | null = null;
const initializeTimeoutPromise = initializeTimeoutMs
? new Promise<never>((_, reject) => {
@@ -1470,11 +1482,11 @@ export class ACPAgentClient implements AgentClient {
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
configuredOptions.clientCapabilities ?? this.clientCapabilities,
clientCapabilities ?? this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
transport.spawnError,
transport.processError,
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
]),
);
@@ -1555,7 +1567,7 @@ export class ACPAgentClient implements AgentClient {
try {
transport = await this.spawnTransport(PROBE_ENV);
await withTimeout(
Promise.race([transport.spawnReady, transport.spawnError]),
Promise.race([transport.spawnReady, transport.processError]),
phaseTimeoutMs,
`ACP spawn timed out after ${phaseTimeoutMs}ms`,
);
@@ -2910,7 +2922,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr.on("data", (chunk: Buffer | string) => {
stderrChunks.push(chunk.toString());
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
const processError = rejectOnProcessFailure(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed || this.historyRuntimeReleased) {
return;
@@ -2947,7 +2959,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
),
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnError,
processError,
]),
);
@@ -0,0 +1,112 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
// Exercise the public catalog API with a real child, including stdout EOF and
// stderr draining. No provider installation or external service is involved.
const failingCommand: [string, ...string[]] = [
process.execPath,
"-e",
"process.stdin.once('data', () => { process.stderr.write('launcher failed: fixture startup error\\n'); process.exitCode = 1; process.stdin.destroy(); });",
];
describe("ACP catalog startup failures", () => {
test("reports a process that exits during initialize with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(
client.fetchCatalog({ scope: "workspace", cwd: process.cwd(), force: true }),
).rejects.toThrow(/launcher failed: fixture startup error/);
}, 5_000);
test("session startup reports the same process exit with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(client.createSession({ provider: "acp", cwd: process.cwd() })).rejects.toThrow(
/launcher failed: fixture startup error/,
);
}, 5_000);
test("provider diagnostics report the startup exit instead of an initialize timeout", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
diagnosticPhaseTimeoutMs: 2_000,
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain(
"ACP agent process exited before initialization completed (exit code 1)",
);
expect(diagnostic).toContain("launcher failed: fixture startup error");
expect(diagnostic).not.toContain("initialize timed out");
});
test("invalid provider options reject without crashing the host process", async () => {
const clientModule = new URL("./generic-acp-agent.ts", import.meta.url).href;
const loggerModule = new URL("../../../test-utils/test-logger.ts", import.meta.url).href;
const script = `
import { GenericACPAgentClient } from ${JSON.stringify(clientModule)};
import { createTestLogger } from ${JSON.stringify(loggerModule)};
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, '-e', 'setInterval(() => {}, 1000)'],
});
try {
await client.fetchCatalog({
scope: 'workspace', cwd: process.cwd(), force: true,
providerOptions: { clientCapabilities: { fs: 'invalid' } },
});
throw new Error('Expected invalid options to reject');
} catch (error) {
if (error.name !== 'ZodError') throw error;
}
// Let child cleanup and Node's unhandled-rejection check complete.
await new Promise(resolve => setTimeout(resolve, 100));
console.log('host stayed alive');
`;
const result = await promisify(execFile)(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", script],
{ timeout: 10_000 },
);
expect(result.stdout).toContain("host stayed alive");
expect(result.stderr).toBe("");
});
test("a successful catalog probe still returns its models and closes cleanly", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [
process.execPath,
"-e",
`
require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => {
const request = JSON.parse(line);
const result = request.method === 'initialize'
? { protocolVersion: 1, agentCapabilities: {} }
: { sessionId: 'fixture', models: {
currentModelId: 'fixture-model',
availableModels: [{ modelId: 'fixture-model', name: 'Fixture model' }]
} };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');
});
`,
],
});
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: process.cwd(),
force: true,
});
expect(catalog.models).toEqual([
expect.objectContaining({ id: "fixture-model", label: "Fixture model" }),
]);
});
});