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
This commit is contained in:
5
.changeset/fn-6911-droid-cli-no-boot-spawn.md
Normal file
5
.changeset/fn-6911-droid-cli-no-boot-spawn.md
Normal file
@@ -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.
|
||||
@@ -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<typeof import("node:child_process")>()),
|
||||
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", () => {
|
||||
|
||||
@@ -24,17 +24,21 @@ type StreamSimpleHandler = NonNullable<Parameters<ExtensionAPI["registerProvider
|
||||
function runCliValidationOnce(): Promise<void> {
|
||||
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<string, unknown> }> }).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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => 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<string, unknown>) => 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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user