fix(cli): pass engine PluginRunner into hosts, not PluginLoader

Stop publishing the bare PluginLoader as createServer.pluginRunner so Grok CLI
routing can resolve getRuntimeById. Dashboard engine mode relies on engine.onMerge;
UI-only/bare CLI omit the runner (dual-remediation). Conflict resolver drops
non-capable runners instead of casting them.
This commit is contained in:
gsxdsm
2026-07-15 10:24:30 -07:00
parent 3748eca073
commit 3676586460
9 changed files with 120 additions and 87 deletions

View File

@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
const commandsDir = resolve(__dirname, "..");
function readCommand(command: "serve" | "daemon" | "dashboard"): string {
function readCommand(command: "serve" | "daemon" | "dashboard" | "desktop" | "task"): string {
return readFileSync(resolve(commandsDir, `${command}.ts`), "utf8");
}
@@ -31,33 +31,51 @@ describe("Grok CLI runtime packaged bootstrap", () => {
});
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
CLI/UI-only merge doors must thread pluginRunner into runAiMerge/landWorkspaceTask when a real PluginRunner is obtainable (engine warm). Bare `fn task merge` has no ProjectEngine — explicitly passes undefined rather than inventing a bootstrap.
FNXC:GrokCliRouting 2026-07-15-10:17:
Hosts must pass a real engine PluginRunner (getRuntimeById) into createServer — never the bare PluginLoader. Engine-mode merge omits onMerge so server.ts derives engine.onMerge. UI-only / bare CLI leave pluginRunner undefined (dual-remediation).
*/
describe("Grok CLI PluginRunner wiring for CLI/UI-only merge doors", () => {
it("dashboard onMergeImpl passes mergePluginRunner into runAiMerge and landWorkspaceTask", () => {
describe("Grok CLI PluginRunner host wiring", () => {
it("dashboard engine mode passes cwdEngine.getPluginRunner and omits onMerge", () => {
const source = readCommand("dashboard");
expect(source).toContain("let mergePluginRunner: PluginRunner | undefined");
expect(source).toContain("mergePluginRunner =");
expect(source).toContain("cwdEngine?.getPluginRunner?.()");
const onMergeImplIndex = source.indexOf("const onMergeImpl = async (taskId: string)");
expect(onMergeImplIndex).toBeGreaterThanOrEqual(0);
const landCall = source.indexOf("landWorkspaceTask(store, mergeTask!, cwd, {", onMergeImplIndex);
const runAiMergeCall = source.indexOf("runAiMerge(store, cwd, taskId, {", onMergeImplIndex);
expect(landCall).toBeGreaterThan(onMergeImplIndex);
expect(runAiMergeCall).toBeGreaterThan(onMergeImplIndex);
expect(source.slice(landCall, landCall + 200)).toContain("pluginRunner");
expect(source.slice(runAiMergeCall, runAiMergeCall + 250)).toContain("pluginRunner");
expect(source).toContain("pluginRunner: cwdEngine?.getPluginRunner?.()");
expect(source).not.toContain("pluginRunner: pluginLoader");
// Engine-mode createServer must not force onMergeImpl — server.ts derives engine.onMerge.
expect(source).toContain("const uiOnlyOnMerge = async (taskId: string)");
expect(source).toContain("onMerge: uiOnlyOnMerge");
// uiOnlyOnMerge must not invent a runner
const uiOnlyIndex = source.indexOf("const uiOnlyOnMerge = async (taskId: string)");
const landCall = source.indexOf("landWorkspaceTask(store, mergeTask!, cwd, {", uiOnlyIndex);
const runAiMergeCall = source.indexOf("runAiMerge(store, cwd, taskId, {", uiOnlyIndex);
expect(landCall).toBeGreaterThan(uiOnlyIndex);
expect(runAiMergeCall).toBeGreaterThan(uiOnlyIndex);
expect(source.slice(landCall, landCall + 280)).toContain("pluginRunner: undefined");
expect(source.slice(runAiMergeCall, runAiMergeCall + 320)).toContain("pluginRunner: undefined");
// No cross-project warm-engine fallback for merge runner
expect(source).not.toContain("let mergePluginRunner");
});
it("fn task merge threads pluginRunner option (undefined without a live ProjectEngine)", () => {
const source = readFileSync(resolve(commandsDir, "task.ts"), "utf8");
it("serve and daemon pass primaryEngine.getPluginRunner, not pluginLoader", () => {
for (const command of ["serve", "daemon"] as const) {
const source = readCommand(command);
expect(source).toContain("pluginRunner: primaryEngine.getPluginRunner?.()");
expect(source).not.toContain("pluginRunner: pluginLoader");
}
});
it("desktop passes cwdEngine.getPluginRunner, not pluginLoader", () => {
const source = readCommand("desktop");
expect(source).toContain("pluginRunner: cwdEngine?.getPluginRunner?.()");
expect(source).not.toContain("pluginRunner: pluginLoader");
});
it("fn task merge does not invent a PluginRunner bootstrap", () => {
const source = readCommand("task");
const mergeFnIndex = source.indexOf("export async function runTaskMerge");
expect(mergeFnIndex).toBeGreaterThanOrEqual(0);
const mergeFnBody = source.slice(mergeFnIndex, source.indexOf("export async function runTaskAttach", mergeFnIndex));
expect(mergeFnBody).toContain("pluginRunner: mergePluginRunner");
expect(mergeFnBody).toContain("FNXC:GrokCliRouting 2026-07-15-09:58");
expect(mergeFnBody).toContain("const mergePluginRunner = undefined");
const nextExport = source.indexOf("export async function runTaskAttach", mergeFnIndex);
const mergeFnBody = source.slice(mergeFnIndex, nextExport > 0 ? nextExport : undefined);
expect(mergeFnBody).toContain("FNXC:GrokCliRouting 2026-07-15-10:17");
expect(mergeFnBody).toContain("Do not invent a full PluginRunner bootstrap");
expect(mergeFnBody).not.toContain("mergePluginRunner");
});
});

View File

@@ -1310,10 +1310,17 @@ describe("project-aware task command behavior", () => {
expect(updateStep).toHaveBeenCalled();
expect(logEntry).toHaveBeenCalled();
// FNXC:GrokCliRouting 2026-07-15-09:58: bare `fn task merge` has no ProjectEngine, so pluginRunner is explicitly undefined (dual-remediation for grok-cli/no-key).
expect(runAiMerge).toHaveBeenCalledWith(resolvedStore, "/test", "FN-123", expect.objectContaining({
pluginRunner: undefined,
}));
// FNXC:GrokCliRouting 2026-07-15-10:17: bare `fn task merge` has no ProjectEngine and does not invent a PluginRunner.
expect(runAiMerge).toHaveBeenCalledWith(
resolvedStore,
"/test",
"FN-123",
expect.objectContaining({
onAgentText: expect.any(Function),
}),
);
const mergeOpts = vi.mocked(runAiMerge).mock.calls.at(-1)?.[3] as { pluginRunner?: unknown } | undefined;
expect(mergeOpts?.pluginRunner).toBeUndefined();
expect(landWorkspaceTask).not.toHaveBeenCalled();
expect(aiMergeTask).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();

View File

@@ -821,7 +821,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
: undefined,
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
/*
FNXC:GrokCliRouting 2026-07-15-10:17:
Pass the engine PluginRunner (getRuntimeById), not the bare PluginLoader, so chat/merge/PR routes can resolve grok-cli/no-key via the Grok runtime. PluginLoader stays on pluginLoader for install/load APIs.
*/
pluginRunner: primaryEngine.getPluginRunner?.(),
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);

View File

@@ -64,7 +64,6 @@ import {
shouldUseHybridExecutor,
setHostExtensionPaths,
createFusionAuthStorage,
type PluginRunner,
} from "@fusion/engine";
import { DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent";
import {
@@ -1469,28 +1468,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// ── onMerge: AI-powered merge ─────────────────────────────────────
//
// onMergeImpl is a mutable reference so createServer always gets a stable
// wrapper function while the underlying implementation is swapped when the
// engine starts in engine mode.
//
// In UI-only mode: calls runAiMerge directly (no engine, no semaphore).
// In engine mode: replaced by engine.onMerge() after ProjectEngine starts
// (semaphore-gated via the engine's InProcessRuntime).
//
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified all merge
// entry points onto runAiMerge (the FN-5633 clean-room AI merge path);
// aiMergeTask is soft-deprecated.
//
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
UI-only onMergeImpl calls runAiMerge/landWorkspaceTask without ProjectEngine's onMerge door, so it must obtain a PluginRunner that exposes getRuntimeById for grok-cli/no-key merge sessions. Prefer the live cwd engine runner (set when engines warm); do not pass the bare PluginLoader (lacks getRuntimeById). When no engine exists (--no-engine), leave undefined so dual-remediation surfaces — do not invent a PluginRunner bootstrap here.
FNXC:GrokCliRouting 2026-07-15-10:17:
Two doors, not a swap-at-runtime:
- Engine mode: createServer is called WITHOUT onMerge so server.ts derives onMerge from engine.onMerge (semaphore + pluginRunner: this.getPluginRunner()).
- UI-only (--no-engine): createServer receives uiOnlyOnMerge which calls runAiMerge/landWorkspaceTask with pluginRunner undefined — dual-remediation for grok-cli/no-key is correct because there is no ProjectEngine PluginRunner. Do not invent a bootstrap here and do not pass the bare PluginLoader (lacks getRuntimeById).
*/
let mergePluginRunner: PluginRunner | undefined;
const onMergeImpl = async (taskId: string) => {
// Prefer the live engine PluginRunner at call time (may be set after engine warm).
const pluginRunner = mergePluginRunner;
const uiOnlyOnMerge = async (taskId: string) => {
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// Dashboard merge button (UI-only mode). A workspace-mode task routes through
// the ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its
@@ -1504,7 +1492,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (isWorkspaceMerge) {
const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, {
agentStore,
pluginRunner,
// FNXC:GrokCliRouting 2026-07-15-10:17: UI-only has no engine PluginRunner.
pluginRunner: undefined,
});
const latest = await store.getTask(taskId).catch(() => mergeTask!);
// FNXC:Workspace 2026-06-22-05:10 (Phase C review B3):
@@ -1556,7 +1545,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
return await runAiMerge(store, cwd, taskId, {
agentStore,
onAgentText: (delta) => streamedMergeLog.push(delta),
pluginRunner,
// FNXC:GrokCliRouting 2026-07-15-10:17: UI-only has no engine PluginRunner.
pluginRunner: undefined,
});
} finally {
streamedMergeLog.flush();
@@ -1564,8 +1554,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
};
const onMerge = (taskId: string) => onMergeImpl(taskId);
// ── MissionAutopilot + MissionExecutionLoop: mission lifecycle ────
//
// Created inline for UI-only mode (engine doesn't start with --no-engine).
@@ -2123,22 +2111,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
)
: undefined;
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
Capture the live engine PluginRunner for any residual UI-only merge door usage. Engine mode replaces onMerge with engine.onMerge (already forwards getPluginRunner); this ref covers onMergeImpl if it is still reached after engines warm. Prefer cwd engine, then any warm engine that exposes getRuntimeById — never the bare PluginLoader.
*/
mergePluginRunner =
cwdEngine?.getPluginRunner?.()
?? (() => {
for (const engine of engineManager.getAllEngines().values()) {
const runner = engine.getPluginRunner?.();
if (runner && typeof runner.getRuntimeById === "function") {
return runner;
}
}
return undefined;
})();
// Get the trigger scheduler from any running engine
for (const engine of engineManager.getAllEngines().values()) {
const ts = engine.getHeartbeatTriggerScheduler();
@@ -2183,6 +2155,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
: undefined;
/*
FNXC:GrokCliRouting 2026-07-15-10:17:
Pass the real engine PluginRunner (getRuntimeById) into createServer — never the bare PluginLoader. Chat, plugin setup/reload, workflow templates, and PR conflict resolution all read options.pluginRunner; the loader lacks getRuntimeById and historically produced the misleading "bundled Grok CLI runtime" error. Omit onMerge so server.ts derives engine.onMerge (semaphore + this.getPluginRunner()).
*/
app = createServer(store, {
engine: cwdEngine,
engineManager,
@@ -2195,7 +2171,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
automationStore,
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
pluginRunner: cwdEngine?.getPluginRunner?.(),
ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {
@@ -2501,8 +2477,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
//
// FNXC:DashboardStartup 2026-06-20-23:39:
// Dashboard development mode still needs a running engine by default; only the explicit `--no-engine` flag should produce a UI-only process so local and dev startup paths match user expectations.
/*
FNXC:GrokCliRouting 2026-07-15-10:17:
UI-only mode has no ProjectEngine PluginRunner. Pass pluginRunner undefined (not pluginLoader) so Grok auto-derive surfaces dual-remediation instead of getRuntimeById TypeError. Plugin management routes that need reloadPlugin degrade via optional chaining on options.pluginRunner.
*/
app = createServer(store, {
onMerge,
onMerge: uiOnlyOnMerge,
centralCore: centralCoreForMesh ?? undefined,
authStorage: dashboardAuthStorage,
modelRegistry,
@@ -2529,7 +2509,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
},
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
pluginRunner: undefined,
ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback,
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);

View File

@@ -90,7 +90,11 @@ async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: b
centralCore,
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
/*
FNXC:GrokCliRouting 2026-07-15-10:17:
Pass the warm engine PluginRunner when available — never the bare PluginLoader (lacks getRuntimeById).
*/
pluginRunner: cwdEngine?.getPluginRunner?.(),
/*
* FNXC:DesktopLauncher 2026-07-01-20:19:
* `fusion desktop --no-auth` is a compatibility flag for users who learned the dashboard launcher semantics. Propagate it to the embedded dashboard server explicitly so desktop routing never treats it as an unknown flag or falls back to source-workspace discovery.

View File

@@ -920,7 +920,11 @@ export async function runServe(
: undefined,
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
/*
FNXC:GrokCliRouting 2026-07-15-10:17:
Pass the engine PluginRunner (getRuntimeById), not the bare PluginLoader, so chat/merge/PR routes can resolve grok-cli/no-key via the Grok runtime. PluginLoader stays on pluginLoader for install/load APIs.
*/
pluginRunner: primaryEngine.getPluginRunner?.(),
ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {

View File

@@ -1023,10 +1023,9 @@ export async function runTaskMerge(id: string, projectName?: string) {
try {
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
`fn task merge` is a bare CLI door: ProjectContext only has store/path, not a live ProjectEngine, so no engine.getPluginRunner() is available. Do not invent a full PluginRunner bootstrap here (that belongs to InProcessRuntime / ProjectEngineManager). Leaving pluginRunner undefined is intentional — grok-cli/no-key merge selections surface the dual-remediation error ("Install and enable the Grok CLI runtime plugin, or set GROK_API_KEY"). Engine-backed merge (dashboard with engine, auto-merge) already forwards this.getPluginRunner().
FNXC:GrokCliRouting 2026-07-15-10:17:
`fn task merge` is a bare CLI door: ProjectContext only has store/path, not a live ProjectEngine, so no engine.getPluginRunner() is available. Do not invent a full PluginRunner bootstrap here (that belongs to InProcessRuntime / ProjectEngineManager). Omitting pluginRunner is intentional — grok-cli/no-key merge selections surface the dual-remediation error. Engine-backed merge already forwards this.getPluginRunner().
*/
const mergePluginRunner = undefined;
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// User-triggered `fn task merge`. A workspace-mode task routes through the
@@ -1041,7 +1040,6 @@ export async function runTaskMerge(id: string, projectName?: string) {
if (isWorkspaceMerge) {
const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, {
onAgentText: (delta) => process.stdout.write(delta),
pluginRunner: mergePluginRunner,
});
console.log();
for (const repo of workspaceResult.repos) {
@@ -1064,7 +1062,6 @@ export async function runTaskMerge(id: string, projectName?: string) {
const result = await runAiMerge(store, projectPath, id, {
onAgentText: (delta) => process.stdout.write(delta),
pluginRunner: mergePluginRunner,
});
console.log();

View File

@@ -201,17 +201,18 @@ describe("resolvePrConflicts", () => {
});
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
Source-level guard: PR conflict route and resolver must thread pluginRunner; bare CLI/UI-only merge doors document the runner handoff.
FNXC:GrokCliRouting 2026-07-15-10:17:
Source-level guard: PR conflict route and resolver must thread a getRuntimeById-capable pluginRunner; non-capable runners are dropped before createResolvedAgentSession.
*/
describe("Grok CLI PluginRunner wiring for PR conflict + merge doors", () => {
const here = dirname(fileURLToPath(import.meta.url));
it("pr-conflict-resolver forwards input.pluginRunner into runResolutionAgent and createResolvedAgentSession", () => {
it("pr-conflict-resolver forwards input.pluginRunner via asSessionPluginRunner into createResolvedAgentSession", () => {
const source = readFileSync(resolve(here, "../pr-conflict-resolver.ts"), "utf8");
expect(source).toContain("pluginRunner?: PluginRunner | { getRuntimeById?(id: string): unknown }");
expect(source).toContain("pluginRunner?: ConflictResolutionPluginRunner");
expect(source).toContain("pluginRunner: input.pluginRunner");
expect(source).toContain("pluginRunner: pluginRunner as PluginRunner | undefined");
expect(source).toContain("pluginRunner: asSessionPluginRunner(pluginRunner)");
expect(source).toContain("function asSessionPluginRunner");
});
it("register-git-github resolve-conflicts prefers engine.getPluginRunner over bare loader", () => {

View File

@@ -14,6 +14,24 @@ const SESSION_PROMPT = [
"When you finish, every conflicted file must be saved without conflict markers.",
].join("\n");
/*
FNXC:GrokCliRouting 2026-07-15-10:17:
Minimum capability for Grok CLI no-key routing is getRuntimeById. Prefer a real engine PluginRunner; structural form is for tests and hosts that only expose that method.
*/
type ConflictResolutionPluginRunner = PluginRunner | {
getRuntimeById?(id: string): unknown;
};
function asSessionPluginRunner(
runner: ConflictResolutionPluginRunner | undefined,
): PluginRunner | undefined {
if (!runner) return undefined;
if (typeof runner.getRuntimeById === "function") {
return runner as PluginRunner;
}
return undefined;
}
export interface ResolvePrConflictsInput {
taskId: string;
baseRef: string;
@@ -21,10 +39,10 @@ export interface ResolvePrConflictsInput {
store: TaskStore;
settings: Settings;
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
Create-PR conflict resolution builds merger-purpose sessions via createResolvedAgentSession. Without a PluginRunner that exposes getRuntimeById, grok-cli/no-key selections cannot resolve the bundled Grok CLI runtime and throw the dual-remediation error even when chat/engine merge work. Optional runner only — callers that lack an engine PluginRunner may omit it. Structural getRuntimeById is the minimum capability; prefer the real engine PluginRunner.
FNXC:GrokCliRouting 2026-07-15-10:17:
Create-PR conflict resolution builds merger-purpose sessions via createResolvedAgentSession. Without a runner that exposes getRuntimeById, grok-cli/no-key selections cannot resolve the bundled Grok CLI runtime. Optional — callers without an engine PluginRunner may omit it.
*/
pluginRunner?: PluginRunner | { getRuntimeById?(id: string): unknown };
pluginRunner?: ConflictResolutionPluginRunner;
}
export interface ResolvePrConflictsResult {
@@ -149,7 +167,7 @@ async function runResolutionAgent(params: {
conflictedFiles: string[];
settings: Settings;
store: TaskStore;
pluginRunner?: PluginRunner | { getRuntimeById?(id: string): unknown };
pluginRunner?: ConflictResolutionPluginRunner;
}): Promise<void> {
const { cwd, taskId, conflictedFiles, settings, store, pluginRunner } = params;
const sessionModel = getDefaultSessionModel(settings);
@@ -159,8 +177,8 @@ async function runResolutionAgent(params: {
*/
const mcpServers = (await resolveMcpServersForStore(store)).servers;
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
Forward pluginRunner into createResolvedAgentSession so grok-cli/no-key default models resolve via getRuntimeById("grok") the same way engine merge and chat do. Bare PluginLoader lacks getRuntimeById — callers must pass the engine PluginRunner (or another runner with that method).
FNXC:GrokCliRouting 2026-07-15-10:17:
Forward a getRuntimeById-capable pluginRunner into createResolvedAgentSession so grok-cli/no-key defaults resolve via the Grok CLI runtime. Drop non-capable runners (e.g. bare PluginLoader) rather than casting them.
*/
const { session } = await createResolvedAgentSession({
cwd,
@@ -173,7 +191,7 @@ async function runResolutionAgent(params: {
fallbackModelId: settings.fallbackModelId,
settings,
mcpServers,
pluginRunner: pluginRunner as PluginRunner | undefined,
pluginRunner: asSessionPluginRunner(pluginRunner),
});
try {