From 5191e1f22de375620bb77a61927e5844ff442be7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:44:26 -0700 Subject: [PATCH] FN-6911: defer Droid CLI startup probes Defer Droid CLI validation and discovery so startup no longer spawns or hangs on droid. - Register the Droid provider without boot-time validation or model discovery side effects. - Trigger CLI validation only when a Droid stream starts and expose explicit model discovery. - Add regression coverage for non-interactive droid process spawning and startup probe behavior. - Add a patch changeset for the published Fusion CLI package. Files changed: .changeset/fn-6911-droid-cli-no-boot-spawn.md | 5 ++ .../commands/__tests__/droid-cli-extension.test.ts | 15 +++- packages/droid-cli/index.ts | 33 ++++---- packages/droid-cli/src/__tests__/index.test.ts | 90 +++++++++------------- .../src/__tests__/discover-models.test.ts | 7 +- .../src/__tests__/probe.test.ts | 8 ++ .../src/__tests__/process-manager.test.ts | 74 ++++++++++++++++++ .../src/__tests__/startup-probes.test.ts | 5 +- 8 files changed, 160 insertions(+), 77 deletions(-) Fusion-Task-Id: FN-6911 Fusion-Task-Lineage: ef4bc3df-bfe8-416a-b701-64f752faea30 --- .changeset/fn-6911-droid-cli-no-boot-spawn.md | 5 + .../__tests__/droid-cli-extension.test.ts | 15 +- packages/droid-cli/index.ts | 33 ++--- .../droid-cli/src/__tests__/index.test.ts | 140 ++++++++---------- .../src/__tests__/discover-models.test.ts | 7 +- .../src/__tests__/probe.test.ts | 8 + .../src/__tests__/process-manager.test.ts | 74 +++++++++ .../src/__tests__/startup-probes.test.ts | 5 +- 8 files changed, 185 insertions(+), 102 deletions(-) create mode 100644 .changeset/fn-6911-droid-cli-no-boot-spawn.md create mode 100644 plugins/fusion-plugin-droid-runtime/src/__tests__/process-manager.test.ts diff --git a/.changeset/fn-6911-droid-cli-no-boot-spawn.md b/.changeset/fn-6911-droid-cli-no-boot-spawn.md new file mode 100644 index 0000000000..6f23dc32e9 --- /dev/null +++ b/.changeset/fn-6911-droid-cli-no-boot-spawn.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Prevent the bundled Droid CLI extension from starting local `droid` probes during server boot; validation now runs only when a Droid stream is actually used while existing probe paths remain non-interactive and timeout-bounded. diff --git a/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts b/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts index 2e30bddc42..00fe8e8ea7 100644 --- a/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts +++ b/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts @@ -1,7 +1,14 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { tempWorkspace } from "@fusion/test-utils"; +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock, +})); + import { resolveDroidCliExtension, resolveDroidCliExtensionPaths, @@ -22,11 +29,15 @@ describe("resolveDroidCliExtension", () => { }); describe("resolveDroidCliExtensionPaths", () => { - it("returns empty when useDroidCli is off (default)", () => { + it("returns empty when useDroidCli is off (default) without spawning droid", () => { + spawnMock.mockClear(); + const result = resolveDroidCliExtensionPaths({}); + expect(result.paths).toEqual([]); expect(result.warning).toBeUndefined(); expect(result.resolution).toBeNull(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("returns empty when useDroidCli is explicitly false", () => { diff --git a/packages/droid-cli/index.ts b/packages/droid-cli/index.ts index 8efac5b7e1..aeb0c373a0 100644 --- a/packages/droid-cli/index.ts +++ b/packages/droid-cli/index.ts @@ -24,17 +24,21 @@ type StreamSimpleHandler = NonNullable { if (cliValidationPromise) return cliValidationPromise; cliValidationPromise = (async () => { - const presence = await validateCliPresenceAsync(); - if (!presence.ok) { - console.warn(`[droid-cli] ${presence.error.message}`); - return; + try { + const presence = await validateCliPresenceAsync(); + if (!presence.ok) { + console.warn(`[droid-cli] ${presence.error.message}`); + return; + } + await validateCliAuthAsync(); + } catch (error) { + console.warn("[droid-cli] CLI validation failed; continuing without blocking the session", error); } - await validateCliAuthAsync(); })(); return cliValidationPromise; } -async function getDiscoveredModels() { +export async function discoverDroidProviderModels() { if (!discoveredModelsPromise) { discoveredModelsPromise = (async () => { try { @@ -104,6 +108,7 @@ function registerDroidProvider(pi: ExtensionAPI, models: DiscoveredModel[]) { api: "droid-cli", models, streamSimple: ((model, context, options) => { + void runCliValidationOnce(); const configPath = ensureMcpConfig( pi, (context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record }> }).tools, @@ -119,10 +124,12 @@ function registerDroidProvider(pi: ExtensionAPI, models: DiscoveredModel[]) { export default function (pi: ExtensionAPI) { /* + FNXC:CliRuntime 2026-06-21-18:43: + Engine and dashboard startup must not start the local Droid CLI merely because the optional extension loaded. Register the provider synchronously with an empty model list, defer validation until an actual droid stream starts, and leave model discovery to explicit picker/status callers so boot with `useDroidCli` enabled still performs zero `droid` spawns. + 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. + Engine and dashboard startup must not wait for local Droid CLI probes. Every surviving validation/discovery helper remains fire-and-forget, bounded, non-interactive, and resolve-only so a missing or wedged `droid` binary cannot stall extension loading or a session start. */ - void runCliValidationOnce(); pi.on("session_start", async () => { const allTools = pi.getAllTools(); @@ -136,14 +143,4 @@ export default function (pi: ExtensionAPI) { } catch (err) { console.error("[droid-cli] Failed to register provider:", err); } - - void (async () => { - const models = await getDiscoveredModels(); - if (models.length === 0) return; - try { - registerDroidProvider(pi, models); - } catch (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 e105d8e767..971806d040 100644 --- a/packages/droid-cli/src/__tests__/index.test.ts +++ b/packages/droid-cli/src/__tests__/index.test.ts @@ -58,59 +58,7 @@ describe("droid-cli extension entrypoint", () => { vi.restoreAllMocks(); }); - it("registers provider droid-cli with discovered model mapping and streamSimple", async () => { - const registerProvider = vi.fn(); - const mockPi = { - registerProvider, - on: vi.fn(), - getAllTools: vi.fn(() => []), - setActiveTools: vi.fn(), - }; - - const mod = await import("../../index"); - mod.default(mockPi as never); - await flushAsyncRegistration(); - - expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1); - expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1); - expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1); - - 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; - models: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>; - streamSimple: Function; - }]; - - expect(providerId).toBe("droid-cli"); - expect(config.baseUrl).toBe("droid-cli"); - expect(config.api).toBe("droid-cli"); - expect(config.apiKey).toBe("unused"); - expect(config.models).toEqual([ - expect.objectContaining({ id: "droid-pro", name: "droid-pro", contextWindow: 200_000, maxTokens: 8_192 }), - expect.objectContaining({ id: "droid-max", name: "droid-max", contextWindow: 200_000, maxTokens: 8_192 }), - ]); - 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; }), - ); - + it("registers provider droid-cli synchronously without starting droid probes or discovery", async () => { const registerProvider = vi.fn(); const mockPi = { registerProvider, @@ -121,23 +69,63 @@ describe("droid-cli extension entrypoint", () => { 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(result).toBeUndefined(); + expect(runtimeMocks.validateCliPresenceAsync).not.toHaveBeenCalled(); + expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled(); + expect(runtimeMocks.discoverDroidModels).not.toHaveBeenCalled(); + + expect(registerProvider).toHaveBeenCalledTimes(1); + const [providerId, config] = registerProvider.mock.calls[0] as [string, { + baseUrl: string; + api: string; + apiKey: string; + models: unknown[]; + streamSimple: Function; + }]; + + expect(providerId).toBe("droid-cli"); + expect(config.baseUrl).toBe("droid-cli"); + expect(config.api).toBe("droid-cli"); + expect(config.apiKey).toBe("unused"); + expect(config.models).toEqual([]); + expect(typeof config.streamSimple).toBe("function"); + }); + + it("runs validation once when a droid stream is actually used", async () => { + const registerProvider = vi.fn(); + const mockPi = { + registerProvider, + on: vi.fn(), + getAllTools: vi.fn(() => []), + setActiveTools: vi.fn(), + }; + + const mod = await import("../../index"); + mod.default(mockPi as never); + const config = registerProvider.mock.calls[0]?.[1] as { + streamSimple: (model: unknown, context: unknown, options?: Record) => unknown; + }; + + config.streamSimple({ id: "droid-pro" }, { messages: [] }, {}); + config.streamSimple({ id: "droid-pro" }, { messages: [] }, {}); + await flushAsyncRegistration(); + + expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1); expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1); - expect(registerProvider).toHaveBeenCalledTimes(2); - expect(registerProvider.mock.calls[1]?.[1]).toMatchObject({ - models: [expect.objectContaining({ id: "droid-pro" })], - }); + expect(runtimeMocks.discoverDroidModels).not.toHaveBeenCalled(); + }); + + it("discovers provider models only when explicitly requested", async () => { + const mod = await import("../../index"); + + await expect(mod.discoverDroidProviderModels()).resolves.toEqual([ + expect.objectContaining({ id: "droid-pro", name: "droid-pro", contextWindow: 200_000, maxTokens: 8_192 }), + expect.objectContaining({ id: "droid-max", name: "droid-max", contextWindow: 200_000, maxTokens: 8_192 }), + ]); + + expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1); }); it("activates all registered tools on session_start", async () => { @@ -177,6 +165,10 @@ describe("droid-cli extension entrypoint", () => { const mod = await import("../../index"); mod.default(mockPi as never); + const config = mockPi.registerProvider.mock.calls[0]?.[1] as { + streamSimple: (model: unknown, context: unknown, options?: Record) => unknown; + }; + config.streamSimple({ id: "droid-pro" }, { messages: [] }, {}); await flushAsyncRegistration(); expect(warnSpy).toHaveBeenCalledWith("[droid-cli] droid CLI missing"); @@ -188,21 +180,9 @@ describe("droid-cli extension entrypoint", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); runtimeMocks.discoverDroidModels.mockRejectedValue(new Error("boom")); - const registerProvider = vi.fn(); - const mockPi = { - registerProvider, - on: vi.fn(), - getAllTools: vi.fn(() => []), - setActiveTools: vi.fn(), - }; - const mod = await import("../../index"); - mod.default(mockPi as never); - await flushAsyncRegistration(); + await expect(mod.discoverDroidProviderModels()).resolves.toEqual([]); - 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/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts index 7580f79ca7..503b72ad47 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 @@ -80,7 +80,12 @@ describe("discoverDroidModels", () => { const models = await discoverDroidModels(); expect(spawnMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledWith("droid", ["exec", "--help"], expect.anything()); + expect(spawnMock).toHaveBeenCalledWith("droid", ["exec", "--help"], expect.objectContaining({ + stdio: ["ignore", "pipe", "ignore"], + })); + const options = spawnMock.mock.calls[0]?.[2] as { stdio: string[] }; + expect(options.stdio).not.toBe("inherit"); + expect(options.stdio).not.toContain("inherit"); expect(models).toContain("claude-opus-4-8"); expect(models).toContain("custom:Kimi-K2.5-Turbo-0"); }); 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 706eea7dc3..c370cba168 100644 --- a/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts @@ -42,6 +42,11 @@ describe("probeDroidBinary", () => { const result = await probeDroidBinary({ timeoutMs: 10 }); expect(result.available).toBe(false); expect(result.reason).toContain("Binary not found or not executable"); + expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ + stdio: ["ignore", "pipe", "pipe"], + })); + const options = spawnMock.mock.calls[0]?.[2] as { stdio: string[] }; + expect(options.stdio).not.toContain("inherit"); }); it("returns unavailable and SIGKILLs when the binary hangs", async () => { @@ -85,6 +90,9 @@ describe("probeDroidBinary", () => { const result = await probeDroidBinary(); expect(result.available).toBe(true); expect(result.version).toBe("droid 1.2.3"); + expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ + stdio: ["ignore", "pipe", "pipe"], + })); }); it("uses binary path from plugin settings", async () => { diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/process-manager.test.ts new file mode 100644 index 0000000000..47ad5de065 --- /dev/null +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/process-manager.test.ts @@ -0,0 +1,74 @@ +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 { buildDroidSpawnArgs, spawnDroid } from "../process-manager.js"; + +function makeProc() { + const proc = new EventEmitter() as any; + proc.killed = false; + proc.exitCode = null; + proc.pid = 123; + proc.kill = vi.fn(() => { + proc.killed = true; + }); + return proc; +} + +describe("Droid agent spawn invariants", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("builds a non-interactive print-mode stream-json invocation", () => { + const args = buildDroidSpawnArgs("droid-pro", undefined, { + effort: "high", + mcpConfigPath: "/tmp/mcp.json", + newSessionId: "session-1", + }); + + expect(args[0]).toBe("-p"); + expect(args).toEqual(expect.arrayContaining([ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--model", + "droid-pro", + "--session-id", + "session-1", + "--effort", + "high", + "--mcp-config", + "/tmp/mcp.json", + ])); + expect(args).not.toContain("models"); + expect(args).not.toContain("model"); + }); + + it("spawns droid with piped stdio and never inherits a TTY", () => { + const proc = makeProc(); + spawnMock.mockReturnValueOnce(proc); + + expect(spawnDroid("droid-pro", undefined, { cwd: "/tmp/project" })).toBe(proc); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [binary, args, options] = spawnMock.mock.calls[0] as [string, string[], { stdio: string[]; cwd: string }]; + expect(binary).toBe("droid"); + expect(args[0]).toBe("-p"); + expect(args).toEqual(expect.arrayContaining(["--input-format", "stream-json"])); + expect(options.cwd).toBe("/tmp/project"); + expect(options.stdio).toEqual(["pipe", "pipe", "pipe"]); + expect(options.stdio).not.toBe("inherit"); + expect(options.stdio).not.toContain("inherit"); + }); +}); 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 index d3e5848264..c6e30d4c8b 100644 --- a/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts @@ -37,9 +37,11 @@ describe("Droid startup validation probes", () => { await expect(validateCliPresenceAsync()).resolves.toMatchObject({ ok: false }); expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ stdio: "ignore" })); + const options = spawnMock.mock.calls[0]?.[2] as { stdio: string }; + expect(options.stdio).not.toBe("inherit"); }); - it("resolves ok when `droid --version` exits 0", async () => { + it("resolves ok when `droid --version` exits 0 without inheriting stdio", async () => { spawnMock.mockImplementationOnce(() => { const proc = makeProbeProc(); queueMicrotask(() => proc.emit("exit", 0)); @@ -47,6 +49,7 @@ describe("Droid startup validation probes", () => { }); await expect(validateCliPresenceAsync()).resolves.toEqual({ ok: true }); + expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ stdio: "ignore" })); }); it("SIGKILLs and resolves unavailable when `droid --version` hangs", async () => {