FN-6878: register Droid provider before probes

Register Droid provider startup without waiting on local binary probes.

- Register the Droid CLI provider synchronously with an empty model list, then refresh discovered models asynchronously.
- Harden Droid runtime probes to convert spawn errors and timeouts into unavailable sentinel results.
- Add coverage for non-blocking startup, failed probes, unavailable binaries, and process cleanup behavior.
- Add a patch changeset for the published Fusion CLI package.

Files changed:
 .changeset/fn-6878-droid-boot.md                   |  5 ++
 packages/droid-cli/index.ts                        | 73 +++++++++++-------
 packages/droid-cli/src/__tests__/index.test.ts     | 52 ++++++++++++-
 .../src/__tests__/process-manager.test.ts          | 58 +++++++--------
 .../src/__tests__/discover-models.test.ts          |  8 ++
 .../src/__tests__/probe.test.ts                    | 43 ++++++++++-
 .../src/__tests__/startup-probes.test.ts           | 86 ++++++++++++++++++++++
 plugins/fusion-plugin-droid-runtime/src/probe.ts   | 32 +++++---
 8 files changed, 283 insertions(+), 74 deletions(-)

Fusion-Task-Id: FN-6878

Fusion-Task-Lineage: 4e44a6e8-0eea-4867-a172-3bdc0c6368e1
This commit is contained in:
gsxdsm
2026-06-21 16:40:32 -07:00
parent 09acfbb707
commit 1f23a2e622
8 changed files with 282 additions and 73 deletions

View File

@@ -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.

View File

@@ -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<string, unknown> }> }).tools,
);
return streamViaCli(
model,
context as never,
{ ...(options ?? {}), mcpConfigPath: configPath } as never,
) as unknown as ReturnType<StreamSimpleHandler>;
}) 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<string, unknown> }> }).tools,
);
return streamViaCli(
model,
context as never,
{ ...(options ?? {}), mcpConfigPath: configPath } as never,
) as unknown as ReturnType<StreamSimpleHandler>;
}) 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);
}
})();
}

View File

@@ -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<void>> = [];
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),

View File

@@ -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([]);
});
});

View File

@@ -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([]);
});
});

View File

@@ -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;

View File

@@ -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"));
});
});

View File

@@ -18,25 +18,37 @@ export function resolveDroidBinaryPath(settings?: Record<string, unknown>): 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<typeof spawn>;
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));
});
}