diff --git a/.changeset/fn-6878-droid-boot.md b/.changeset/fn-6878-droid-boot.md new file mode 100644 index 0000000000..abbf7c40b1 --- /dev/null +++ b/.changeset/fn-6878-droid-boot.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Ensure bundled Droid CLI provider startup registers without waiting for local `droid` probes and harden binary probes so missing, guarded, or hanging spawns resolve to unavailable sentinels instead of delaying engine boot. diff --git a/packages/droid-cli/index.ts b/packages/droid-cli/index.ts index 9d0bec4f7f..8efac5b7e1 100644 --- a/packages/droid-cli/index.ts +++ b/packages/droid-cli/index.ts @@ -40,15 +40,7 @@ async function getDiscoveredModels() { try { const ids = Array.from(new Set(await discoverDroidModels())); if (ids.length === 0) return []; - return ids.map((id) => ({ - id, - name: id, - reasoning: true, - input: ["text", "image"] as Array<"text" | "image">, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - })); + return toProviderModels(ids); } catch (error) { console.warn("[droid-cli] model auto-discovery failed; registering provider with empty model list", error); return []; @@ -60,6 +52,18 @@ async function getDiscoveredModels() { let cachedMcpConfig: { hash: string; configPath: string } | undefined; +function toProviderModels(ids: string[]): DiscoveredModel[] { + return ids.map((id) => ({ + id, + name: id, + reasoning: true, + input: ["text", "image"] as Array<"text" | "image">, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + })); +} + function ensureMcpConfig( pi: ExtensionAPI, contextTools?: ReadonlyArray<{ @@ -93,7 +97,31 @@ function ensureMcpConfig( } } +function registerDroidProvider(pi: ExtensionAPI, models: DiscoveredModel[]) { + pi.registerProvider(PROVIDER_ID, { + baseUrl: "droid-cli", + apiKey: "unused", + api: "droid-cli", + models, + streamSimple: ((model, context, options) => { + const configPath = ensureMcpConfig( + pi, + (context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record }> }).tools, + ); + return streamViaCli( + model, + context as never, + { ...(options ?? {}), mcpConfigPath: configPath } as never, + ) as unknown as ReturnType; + }) as StreamSimpleHandler, + }); +} + export default function (pi: ExtensionAPI) { + /* + FNXC:CliRuntime 2026-06-21-12:00: + Engine and dashboard startup must not wait for local Droid CLI probes. Register the provider synchronously with an empty model list, then launch presence/auth/model discovery as fire-and-forget bounded probes so a missing or wedged `droid` binary cannot stall extension loading. + */ void runCliValidationOnce(); pi.on("session_start", async () => { @@ -103,28 +131,19 @@ export default function (pi: ExtensionAPI) { } }); + try { + registerDroidProvider(pi, []); + } catch (err) { + console.error("[droid-cli] Failed to register provider:", err); + } + void (async () => { const models = await getDiscoveredModels(); + if (models.length === 0) return; try { - pi.registerProvider(PROVIDER_ID, { - baseUrl: "droid-cli", - apiKey: "unused", - api: "droid-cli", - models, - streamSimple: ((model, context, options) => { - const configPath = ensureMcpConfig( - pi, - (context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record }> }).tools, - ); - return streamViaCli( - model, - context as never, - { ...(options ?? {}), mcpConfigPath: configPath } as never, - ) as unknown as ReturnType; - }) as StreamSimpleHandler, - }); + registerDroidProvider(pi, models); } catch (err) { - console.error("[droid-cli] Failed to register provider:", err); + console.error("[droid-cli] Failed to refresh discovered provider models:", err); } })(); } diff --git a/packages/droid-cli/src/__tests__/index.test.ts b/packages/droid-cli/src/__tests__/index.test.ts index 06de6112da..e105d8e767 100644 --- a/packages/droid-cli/src/__tests__/index.test.ts +++ b/packages/droid-cli/src/__tests__/index.test.ts @@ -75,8 +75,14 @@ describe("droid-cli extension entrypoint", () => { expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1); expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1); - expect(registerProvider).toHaveBeenCalledTimes(1); - const [providerId, config] = registerProvider.mock.calls[0] as [string, { + expect(registerProvider).toHaveBeenCalledTimes(2); + const [initialProviderId, initialConfig] = registerProvider.mock.calls[0] as [string, { + models: unknown[]; + }]; + expect(initialProviderId).toBe("droid-cli"); + expect(initialConfig.models).toEqual([]); + + const [providerId, config] = registerProvider.mock.calls.at(-1) as [string, { baseUrl: string; api: string; apiKey: string; @@ -95,6 +101,45 @@ describe("droid-cli extension entrypoint", () => { expect(typeof config.streamSimple).toBe("function"); }); + it("registers the provider synchronously without awaiting droid probes", async () => { + let resolvePresence!: (value: { ok: true }) => void; + let resolveDiscovery!: (value: string[]) => void; + runtimeMocks.validateCliPresenceAsync.mockImplementation( + () => new Promise((resolve) => { resolvePresence = resolve; }), + ); + runtimeMocks.discoverDroidModels.mockImplementation( + () => new Promise((resolve) => { resolveDiscovery = resolve; }), + ); + + const registerProvider = vi.fn(); + const mockPi = { + registerProvider, + on: vi.fn(), + getAllTools: vi.fn(() => []), + setActiveTools: vi.fn(), + }; + + const mod = await import("../../index"); + const result = mod.default(mockPi as never); + + expect(result).toBeUndefined(); + expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1); + expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1); + expect(registerProvider).toHaveBeenCalledTimes(1); + expect(registerProvider).toHaveBeenCalledWith("droid-cli", expect.objectContaining({ models: [] })); + expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled(); + + resolvePresence({ ok: true }); + resolveDiscovery(["droid-pro"]); + await flushAsyncRegistration(); + + expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1); + expect(registerProvider).toHaveBeenCalledTimes(2); + expect(registerProvider.mock.calls[1]?.[1]).toMatchObject({ + models: [expect.objectContaining({ id: "droid-pro" })], + }); + }); + it("activates all registered tools on session_start", async () => { const sessionStartHandlers: Array<() => Promise> = []; const mockPi = { @@ -136,7 +181,7 @@ describe("droid-cli extension entrypoint", () => { expect(warnSpy).toHaveBeenCalledWith("[droid-cli] droid CLI missing"); expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled(); - expect(mockPi.registerProvider).toHaveBeenCalledTimes(1); + expect(mockPi.registerProvider).toHaveBeenCalledWith("droid-cli", expect.objectContaining({ models: [] })); }); it("falls back to empty models when discovery throws", async () => { @@ -157,6 +202,7 @@ describe("droid-cli extension entrypoint", () => { const config = registerProvider.mock.calls[0]?.[1] as { models: unknown[] }; expect(config.models).toEqual([]); + expect(registerProvider).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( "[droid-cli] model auto-discovery failed; registering provider with empty model list", expect.any(Error), diff --git a/packages/droid-cli/src/__tests__/process-manager.test.ts b/packages/droid-cli/src/__tests__/process-manager.test.ts index a4df93ae37..013bae1caf 100644 --- a/packages/droid-cli/src/__tests__/process-manager.test.ts +++ b/packages/droid-cli/src/__tests__/process-manager.test.ts @@ -748,52 +748,44 @@ describe("discoverDroidModels", () => { vi.clearAllMocks(); }); - it("parses model ids from JSON output", async () => { + it("parses model ids from droid exec --help output", async () => { (spawn as any).mockImplementationOnce(() => { const EventEmitter = require("node:events"); const proc = new EventEmitter(); proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); setTimeout(() => { - proc.stdout.emit("data", Buffer.from('[{"id":"droid-pro"},{"name":"droid-max"}]')); + proc.stdout.emit("data", Buffer.from(`Usage: droid exec [options] [prompt] + +Available Models: + droid-pro Droid Pro + droid-max Droid Max + +Model details: + - Droid Pro: prose, not a model id +`)); proc.emit("exit", 0); }, 0); return proc; }); await expect(discoverDroidModels()).resolves.toEqual(["droid-pro", "droid-max"]); + expect(spawn).toHaveBeenCalledWith("droid", ["exec", "--help"], expect.anything()); }); - it("falls back across attempts and parses newline output", async () => { - (spawn as any) - .mockImplementationOnce(() => { - const EventEmitter = require("node:events"); - const proc = new EventEmitter(); - proc.stdout = new EventEmitter(); - proc.stderr = new EventEmitter(); - setTimeout(() => proc.emit("exit", 1), 0); - return proc; - }) - .mockImplementationOnce(() => { - const EventEmitter = require("node:events"); - const proc = new EventEmitter(); - proc.stdout = new EventEmitter(); - proc.stderr = new EventEmitter(); - setTimeout(() => proc.emit("exit", 1), 0); - return proc; - }) - .mockImplementationOnce(() => { - const EventEmitter = require("node:events"); - const proc = new EventEmitter(); - proc.stdout = new EventEmitter(); - proc.stderr = new EventEmitter(); - setTimeout(() => { - proc.stdout.emit("data", Buffer.from("droid-lite\ndroid-lite\ndroid-pro\n")); - proc.emit("exit", 0); - }, 0); - return proc; - }); + it("returns [] when droid exec --help exits without a model section", async () => { + (spawn as any).mockImplementationOnce(() => { + const EventEmitter = require("node:events"); + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + setTimeout(() => { + proc.stdout.emit("data", Buffer.from("Usage: droid exec\n\nOptions:\n --help\n")); + proc.emit("exit", 0); + }, 0); + return proc; + }); - await expect(discoverDroidModels()).resolves.toEqual(["droid-lite", "droid-pro"]); + await expect(discoverDroidModels()).resolves.toEqual([]); }); }); diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts index bb83f97d24..7580f79ca7 100644 --- a/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts @@ -106,4 +106,12 @@ describe("discoverDroidModels", () => { await expect(discoverDroidModels()).resolves.toEqual([]); }); + + it("returns [] instead of rejecting when spawn throws synchronously", async () => { + spawnMock.mockImplementationOnce(() => { + throw new Error("Real AI CLI launch blocked during tests: droid exec --help"); + }); + + await expect(discoverDroidModels()).resolves.toEqual([]); + }); }); diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts index 2968cc3ed1..706eea7dc3 100644 --- a/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; @@ -10,11 +10,26 @@ vi.mock("node:child_process", () => ({ import { probeDroidBinary, resolveDroidBinaryPath } from "../probe.js"; +function makeProbeProc() { + const proc = new EventEmitter() as any; + proc.stdout = new PassThrough(); + proc.stderr = new PassThrough(); + proc.killed = false; + proc.kill = vi.fn(() => { + proc.killed = true; + }); + return proc; +} + describe("probeDroidBinary", () => { beforeEach(() => { spawnMock.mockReset(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("returns unavailable when binary is missing", async () => { spawnMock.mockImplementationOnce(() => { const proc = new EventEmitter() as any; @@ -29,6 +44,32 @@ describe("probeDroidBinary", () => { expect(result.reason).toContain("Binary not found or not executable"); }); + it("returns unavailable and SIGKILLs when the binary hangs", async () => { + vi.useFakeTimers(); + const proc = makeProbeProc(); + spawnMock.mockImplementationOnce(() => proc); + + const pending = probeDroidBinary({ timeoutMs: 50 }); + await vi.advanceTimersByTimeAsync(51); + + await expect(pending).resolves.toMatchObject({ + available: false, + reason: "Probe timed out after 50ms", + }); + expect(proc.kill).toHaveBeenCalledWith("SIGKILL"); + }); + + it("returns unavailable when spawn throws synchronously", async () => { + spawnMock.mockImplementationOnce(() => { + throw new Error("Real AI CLI launch blocked during tests: droid --version"); + }); + + await expect(probeDroidBinary({ timeoutMs: 10 })).resolves.toMatchObject({ + available: false, + reason: "Binary not found or not executable: droid", + }); + }); + it("returns available and version on success", async () => { spawnMock.mockImplementationOnce(() => { const proc = new EventEmitter() as any; diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts new file mode 100644 index 0000000000..d3e5848264 --- /dev/null +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +import { validateCliAuthAsync, validateCliPresenceAsync } from "../process-manager.js"; + +function makeProbeProc() { + const proc = new EventEmitter() as any; + proc.killed = false; + proc.kill = vi.fn(() => { + proc.killed = true; + }); + return proc; +} + +describe("Droid startup validation probes", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("resolves unavailable when `droid --version` emits ENOENT", async () => { + spawnMock.mockImplementationOnce(() => { + const proc = makeProbeProc(); + queueMicrotask(() => proc.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" }))); + return proc; + }); + + await expect(validateCliPresenceAsync()).resolves.toMatchObject({ ok: false }); + expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ stdio: "ignore" })); + }); + + it("resolves ok when `droid --version` exits 0", async () => { + spawnMock.mockImplementationOnce(() => { + const proc = makeProbeProc(); + queueMicrotask(() => proc.emit("exit", 0)); + return proc; + }); + + await expect(validateCliPresenceAsync()).resolves.toEqual({ ok: true }); + }); + + it("SIGKILLs and resolves unavailable when `droid --version` hangs", async () => { + vi.useFakeTimers(); + const proc = makeProbeProc(); + spawnMock.mockImplementationOnce(() => proc); + + const pending = validateCliPresenceAsync(); + await vi.advanceTimersByTimeAsync(45_001); + + await expect(pending).resolves.toMatchObject({ ok: false }); + expect(proc.kill).toHaveBeenCalledWith("SIGKILL"); + }); + + it("resolves false when `droid auth status` exits non-zero", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + spawnMock.mockImplementationOnce(() => { + const proc = makeProbeProc(); + queueMicrotask(() => proc.emit("exit", 1)); + return proc; + }); + + await expect(validateCliAuthAsync()).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not authenticated")); + expect(spawnMock).toHaveBeenCalledWith("droid", ["auth", "status"], expect.objectContaining({ stdio: "ignore" })); + }); + + it("resolves false instead of rejecting when auth spawn throws synchronously", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + spawnMock.mockImplementationOnce(() => { + throw new Error("Real AI CLI launch blocked during tests: droid auth status"); + }); + + await expect(validateCliAuthAsync()).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not authenticated")); + }); +}); diff --git a/plugins/fusion-plugin-droid-runtime/src/probe.ts b/plugins/fusion-plugin-droid-runtime/src/probe.ts index 3effb6bc00..dbb3edd0bd 100644 --- a/plugins/fusion-plugin-droid-runtime/src/probe.ts +++ b/plugins/fusion-plugin-droid-runtime/src/probe.ts @@ -18,25 +18,37 @@ export function resolveDroidBinaryPath(settings?: Record): stri async function run(binary: string, args: string[], timeoutMs = 2000): Promise<{ code: number | null; stdout: string; stderr: string }> { return new Promise((resolve) => { - const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; + let child: ReturnType; + try { + child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] }); + } catch { + resolve({ code: 127, stdout, stderr }); + return; + } + + /* + FNXC:CliRuntime 2026-06-21-12:00: + Droid binary probes run on dashboard and engine startup status paths, so they must never reject or wait forever. Convert synchronous spawn guards, ENOENT, and timeout hangs into sentinel exit codes so boot degrades provider availability instead of blocking on a broken local `droid` install. + */ + let settled = false; + const settle = (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }; const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { // ignore kill errors } - resolve({ code: 124, stdout, stderr }); + settle(124); }, timeoutMs); child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); }); child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); }); - child.on("error", () => { - clearTimeout(timer); - resolve({ code: 127, stdout, stderr }); - }); - child.on("close", (code) => { - clearTimeout(timer); - resolve({ code, stdout, stderr }); - }); + child.on("error", () => settle(127)); + child.on("close", (code) => settle(code)); }); }