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.
This commit is contained in:
can1357
2026-02-20 12:28:25 +01:00
parent 35c90ed859
commit 94ee2f99ac
3 changed files with 44 additions and 11 deletions
@@ -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<void>();
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;
}
/**
@@ -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<never> {
// 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`);
};
@@ -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__/);
});
});