From 94ee2f99ac1535cddf62310e31909176a174db7e Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 20 Feb 2026 12:28:25 +0100 Subject: [PATCH] fix(coding-agent): use ready-signal protocol for RPC client startup Replace flaky time-based heuristic (sleep + 500ms race) with a proper ready-signal protocol. The RPC server now emits {"type":"ready"} on stdout when initialized, and the client waits for that signal instead of guessing based on timing. Fixes CI failure where the process took longer than 600ms to reach provider validation (due to auth discovery + model registry refresh), causing start() to resolve even when the process was about to exit. --- .../coding-agent/src/modes/rpc/rpc-client.ts | 51 +++++++++++++++---- .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 + .../test/rpc-client.start.test.ts | 2 +- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7596cb2c2..2b08b0f6b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -123,23 +123,54 @@ export class RpcClient { stdin: "pipe", }); - // Process lines in background + // Wait for the "ready" signal or process exit + const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers(); + let readySettled = false; + + // Process lines in background, intercepting the ready signal const lines = readJsonl(this.#process.stdout, this.#abortController.signal); void (async () => { for await (const line of lines) { + if (!readySettled && isRecord(line) && line.type === "ready") { + readySettled = true; + readyResolve(); + continue; + } this.#handleLine(line); } - })().catch(() => {}); + // Stream ended without ready signal — process exited + if (!readySettled) { + readySettled = true; + readyReject(new Error(`Agent process exited before ready. Stderr: ${this.#process?.peekStderr() ?? ""}`)); + } + })().catch((err: Error) => { + if (!readySettled) { + readySettled = true; + readyReject(err); + } + }); - // Wait a moment for process to initialize - await Bun.sleep(100); + // Also race against process exit (in case stdout closes before we read it) + void this.#process.exited.then((exitCode: number) => { + if (!readySettled) { + readySettled = true; + readyReject( + new Error(`Agent process exited with code ${exitCode}. Stderr: ${this.#process?.peekStderr() ?? ""}`), + ); + } + }); - const exitCode = await Promise.race([this.#process.exited, Bun.sleep(500).then(() => null)]); - if (exitCode !== null) { - throw new Error( - `Agent process exited immediately with code ${exitCode}. Stderr: ${this.#process.peekStderr()}`, - ); - } + // Timeout to prevent hanging forever + void Bun.sleep(30000).then(() => { + if (!readySettled) { + readySettled = true; + readyReject( + new Error(`Timeout waiting for agent to become ready. Stderr: ${this.#process?.peekStderr() ?? ""}`), + ); + } + }); + + await readyPromise; } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 4988913f3..66c75893a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -36,6 +36,8 @@ export type { * Listens for JSON commands on stdin, outputs events and responses on stdout. */ export async function runRpcMode(session: AgentSession): Promise { + // Signal to RPC clients that the server is ready to accept commands + process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`); const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { process.stdout.write(`${JSON.stringify(obj)}\n`); }; diff --git a/packages/coding-agent/test/rpc-client.start.test.ts b/packages/coding-agent/test/rpc-client.start.test.ts index 7009b93ef..f938564b1 100644 --- a/packages/coding-agent/test/rpc-client.start.test.ts +++ b/packages/coding-agent/test/rpc-client.start.test.ts @@ -12,6 +12,6 @@ describe("RpcClient.start", () => { env: { PI_NO_TITLE: "1" }, }); - await expect(client.start()).rejects.toThrow('Unknown provider "__missing_provider__"'); + await expect(client.start()).rejects.toThrow(/Unknown provider.*__missing_provider__/); }); });