FN-6808: handle AI CLI probe spawn failures

Resolve synchronous AI CLI probe launch failures as unavailable auth states.

- Catch synchronous spawn failures in Claude and Droid probe helpers so fire-and-forget validation paths do not reject.
- Return unavailable/unauthenticated sentinels for Droid model discovery and CLI presence/auth checks.
- Add regression coverage for Claude and Droid presence/auth probes plus a patch changeset.

Files changed:
 .../fn-6808-cli-probe-unhandled-rejection.md       |  5 ++++
 .../src/__tests__/process-manager.test.ts          | 25 ++++++++++++++++++++
 .../src/__tests__/process-manager.test.ts          | 25 ++++++++++++++++++++
 packages/pi-claude-cli/src/process-manager.ts      | 12 +++++++++-
 .../src/process-manager.ts                         | 27 +++++++++++++++++++---
 5 files changed, 90 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-6808

Fusion-Task-Lineage: 79702ef2-e116-4313-a557-da17a6888c30
This commit is contained in:
gsxdsm
2026-06-20 21:30:53 -07:00
parent 185ff70d86
commit 37c4cfa56e
5 changed files with 90 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Prevent bundled Droid and Claude CLI auth/presence probes from surfacing unhandled promise rejections when `spawn` throws synchronously, such as when test guards block real AI CLI auth commands. These probes now resolve as unavailable/unauthenticated instead of rejecting from fire-and-forget validation paths.

View File

@@ -415,6 +415,16 @@ describe("validateCliPresenceAsync", () => {
const result = await validateCliPresenceAsync();
expect(result.ok).toBe(false);
});
it("resolves ok=false instead of rejecting when droid spawn throws synchronously", async () => {
(spawn as any).mockImplementationOnce(() => {
throw new Error("Real AI CLI launch blocked during tests: droid --version");
});
await expect(validateCliPresenceAsync()).resolves.toMatchObject({
ok: false,
});
});
});
describe("validateCliAuthAsync", () => {
@@ -452,6 +462,21 @@ describe("validateCliAuthAsync", () => {
);
warnSpy.mockRestore();
});
it("resolves false instead of rejecting when droid auth spawn throws synchronously", async () => {
(spawn as any).mockImplementationOnce(() => {
throw new Error(
"Real AI CLI launch blocked during tests: droid auth status",
);
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(validateCliAuthAsync()).resolves.toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not authenticated"),
);
warnSpy.mockRestore();
});
});
describe("CLI flags", () => {

View File

@@ -414,6 +414,16 @@ describe("validateCliPresenceAsync", () => {
const result = await validateCliPresenceAsync();
expect(result.ok).toBe(false);
});
it("resolves ok=false instead of rejecting when claude spawn throws synchronously", async () => {
(spawn as any).mockImplementationOnce(() => {
throw new Error("Real AI CLI launch blocked during tests: claude --version");
});
await expect(validateCliPresenceAsync()).resolves.toMatchObject({
ok: false,
});
});
});
describe("validateCliAuthAsync", () => {
@@ -451,6 +461,21 @@ describe("validateCliAuthAsync", () => {
);
warnSpy.mockRestore();
});
it("resolves false instead of rejecting when claude auth spawn throws synchronously", async () => {
(spawn as any).mockImplementationOnce(() => {
throw new Error(
"Real AI CLI launch blocked during tests: claude auth status",
);
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(validateCliAuthAsync()).resolves.toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not authenticated"),
);
warnSpy.mockRestore();
});
});
describe("CLI flags", () => {

View File

@@ -219,10 +219,20 @@ export function captureStderr(proc: ChildProcess): () => string {
* does this on every chat send), sync probes freeze every other request.
* This async variant uses spawn so the loop keeps turning while the subprocess
* starts up.
*
* FNXC:CliRuntime 2026-06-20-17:25:
* FN-6808/FN-6801 require this fire-and-forget auth/presence probe to never reject. Catch synchronous spawn throws from the Vitest child-process guard or platform launch errors and resolve 127, matching the async error sentinel so callers degrade to unauthenticated/not-present instead of surfacing unhandled promise rejections.
*/
function runClaudeProbe(args: string[], timeoutMs = 5000): Promise<number> {
return new Promise((resolve) => {
const proc = spawn("claude", args, { stdio: "ignore" });
let proc: ChildProcess;
try {
proc = spawn("claude", args, { stdio: "ignore" });
} catch {
resolve(127);
return;
}
const timer = setTimeout(() => {
try {
proc.kill("SIGKILL");

View File

@@ -219,10 +219,20 @@ export function captureStderr(proc: ChildProcess): () => string {
* does this on every chat send), sync probes freeze every other request.
* This async variant uses spawn so the loop keeps turning while the subprocess
* starts up.
*
* FNXC:CliRuntime 2026-06-20-17:25:
* FN-6808/FN-6801 require this fire-and-forget auth/presence probe to never reject. Catch synchronous spawn throws from the Vitest child-process guard or platform launch errors and resolve 127, matching the async error sentinel so callers degrade to unauthenticated/not-present instead of surfacing unhandled promise rejections.
*/
function runDroidProbe(args: string[], timeoutMs = 45000): Promise<number> {
return new Promise((resolve) => {
const proc = spawn("droid", args, { stdio: "ignore" });
let proc: ChildProcess;
try {
proc = spawn("droid", args, { stdio: "ignore" });
} catch {
resolve(127);
return;
}
const timer = setTimeout(() => {
try {
proc.kill("SIGKILL");
@@ -275,11 +285,22 @@ export async function validateCliAuthAsync(): Promise<boolean> {
}
export async function discoverDroidModels(): Promise<string[]> {
const attempts: string[][] = [["models", "--json"], ["model", "list", "--json"], ["models"]];
const attempts: string[][] = [
["models", "--json"],
["model", "list", "--json"],
["models"],
];
for (const args of attempts) {
const models = await new Promise<string[] | null>((resolve) => {
const proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
let proc: ChildProcess;
try {
proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
} catch {
resolve(null);
return;
}
let out = "";
proc.stdout?.on("data", (chunk: Buffer) => {
out += chunk.toString();