fix(cli): forward PluginRunner into UI/CLI merge and PR conflict doors

Thread a real engine PluginRunner (getRuntimeById) into runAiMerge,
landWorkspaceTask, and create-PR conflict resolution so grok-cli/no-key
sessions resolve the Grok runtime. Bare fn task merge keeps pluginRunner
undefined rather than inventing a bootstrap.
This commit is contained in:
gsxdsm
2026-07-15 10:07:21 -07:00
parent 4d78f9641b
commit 30f9cac46a
7 changed files with 195 additions and 6 deletions

View File

@@ -29,3 +29,35 @@ 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.
*/
describe("Grok CLI PluginRunner wiring for CLI/UI-only merge doors", () => {
it("dashboard onMergeImpl passes mergePluginRunner into runAiMerge and landWorkspaceTask", () => {
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");
});
it("fn task merge threads pluginRunner option (undefined without a live ProjectEngine)", () => {
const source = readFileSync(resolve(commandsDir, "task.ts"), "utf8");
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");
});
});

View File

@@ -1310,7 +1310,10 @@ describe("project-aware task command behavior", () => {
expect(updateStep).toHaveBeenCalled();
expect(logEntry).toHaveBeenCalled();
expect(runAiMerge).toHaveBeenCalledWith(resolvedStore, "/test", "FN-123", expect.any(Object));
// 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,
}));
expect(landWorkspaceTask).not.toHaveBeenCalled();
expect(aiMergeTask).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();

View File

@@ -64,6 +64,7 @@ import {
shouldUseHybridExecutor,
setHostExtensionPaths,
createFusionAuthStorage,
type PluginRunner,
} from "@fusion/engine";
import { DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent";
import {
@@ -1480,7 +1481,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// 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.
*/
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;
// 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
@@ -1494,6 +1504,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (isWorkspaceMerge) {
const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, {
agentStore,
pluginRunner,
});
const latest = await store.getTask(taskId).catch(() => mergeTask!);
// FNXC:Workspace 2026-06-22-05:10 (Phase C review B3):
@@ -1545,6 +1556,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
return await runAiMerge(store, cwd, taskId, {
agentStore,
onAgentText: (delta) => streamedMergeLog.push(delta),
pluginRunner,
});
} finally {
streamedMergeLog.flush();
@@ -2111,6 +2123,22 @@ 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();

View File

@@ -1022,6 +1022,12 @@ export async function runTaskMerge(id: string, projectName?: string) {
console.log(`\n Merging ${id} with AI...\n`);
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().
*/
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
// ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own
@@ -1035,6 +1041,7 @@ 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) {
@@ -1057,6 +1064,7 @@ 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

@@ -17,11 +17,14 @@ vi.mock("../routes/resolve-diff-base.js", () => ({
vi.mock("@fusion/engine", () => ({
// FNXC:TestInfrastructure 2026-07-13-11:05: Missing @fusion/engine barrel exports added for mock completeness (check-mock-completeness.mjs gate).
resolveMcpServersForStore: vi.fn(() => []),
resolveMcpServersForStore: vi.fn(async () => ({ servers: [] })),
createResolvedAgentSession: mockCreateResolvedAgentSession,
}));
import { resolvePrConflicts } from "../pr-conflict-resolver.js";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
function createTask(overrides: Partial<Task> = {}): Task {
return {
@@ -129,4 +132,96 @@ describe("resolvePrConflicts", () => {
expect(mockRunGitCommand).toHaveBeenCalledWith(["push", "-u", "origin", "fusion/fn-001"], expect.stringContaining("conflict-fn-001"), 60000);
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Pushed PR branch after conflict-free merge", "fusion/fn-001");
});
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
Create-PR conflict resolution must forward pluginRunner into createResolvedAgentSession so grok-cli/no-key models resolve getRuntimeById("grok") the same way engine merge does.
*/
it("forwards optional pluginRunner into createResolvedAgentSession during AI conflict resolution", async () => {
const rootDir = await createRootDir();
rootDirs.push(rootDir);
const store = createStore(createTask());
const { writeFile, mkdir } = await import("node:fs/promises");
// Temp worktree path used by the resolver when task.worktree is missing.
const worktreePath = join(rootDir, ".fusion", "worktrees", "conflict-fn-001");
const pluginRunner = {
getRuntimeById: vi.fn().mockReturnValue({ pluginId: "fusion-plugin-grok-runtime", runtime: {} }),
};
mockCreateResolvedAgentSession.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
});
mockRunGitCommand.mockImplementation(async (args: string[], cwd?: string) => {
const cmd = args.join(" ");
if (cmd.startsWith("worktree add")) return "";
if (cmd.startsWith("checkout")) return "";
if (cmd.startsWith("merge --no-commit")) {
throw Object.assign(new Error("CONFLICT"), { code: 1 });
}
if (cmd === "diff --name-only --diff-filter=U") {
// Ensure the conflicted file exists under the worktree cwd so marker scan can run.
await mkdir(cwd ?? worktreePath, { recursive: true }).catch(() => undefined);
await writeFile(join(cwd ?? worktreePath, "conflicted.txt"), "resolved content\n", "utf8");
return "conflicted.txt\n";
}
if (cmd.startsWith("add -A")) return "";
if (cmd.startsWith("diff --cached --quiet")) {
throw Object.assign(new Error("diff has changes"), { code: 1 });
}
if (cmd.startsWith("commit")) return "";
if (cmd.startsWith("push")) return "";
if (cmd.startsWith("worktree remove")) return "";
if (cmd.startsWith("merge --abort") || cmd.startsWith("reset --merge")) return "";
return "";
});
const result = await resolvePrConflicts({
taskId: "FN-001",
baseRef: "main",
rootDir,
store,
settings,
pluginRunner,
});
expect(result.resolved).toBe(true);
expect(mockCreateResolvedAgentSession).toHaveBeenCalledTimes(1);
expect(mockCreateResolvedAgentSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionPurpose: "merger",
pluginRunner,
}),
);
});
});
/*
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.
*/
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", () => {
const source = readFileSync(resolve(here, "../pr-conflict-resolver.ts"), "utf8");
expect(source).toContain("pluginRunner?: PluginRunner | { getRuntimeById?(id: string): unknown }");
expect(source).toContain("pluginRunner: input.pluginRunner");
expect(source).toContain("pluginRunner: pluginRunner as PluginRunner | undefined");
});
it("register-git-github resolve-conflicts prefers engine.getPluginRunner over bare loader", () => {
const source = readFileSync(resolve(here, "../routes/register-git-github.ts"), "utf8");
const routeIndex = source.indexOf('router.post("/tasks/:id/pr/resolve-conflicts"');
expect(routeIndex).toBeGreaterThanOrEqual(0);
const callIndex = source.indexOf("resolvePrConflicts({", routeIndex);
expect(callIndex).toBeGreaterThan(routeIndex);
expect(source.slice(routeIndex, callIndex)).toContain("engine?.getPluginRunner?.()");
expect(source.slice(routeIndex, callIndex + 400)).toContain("pluginRunner,");
expect(source.slice(routeIndex, callIndex)).toContain("getRuntimeById");
});
});

View File

@@ -1,7 +1,7 @@
import { access, mkdir, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { Settings, TaskStore } from "@fusion/core";
import { createResolvedAgentSession, resolveMcpServersForStore } from "@fusion/engine";
import { createResolvedAgentSession, resolveMcpServersForStore, type PluginRunner } from "@fusion/engine";
import { runGitCommand } from "./routes/resolve-diff-base.js";
const GIT_TIMEOUT_MS = 60_000;
@@ -20,6 +20,11 @@ export interface ResolvePrConflictsInput {
rootDir: string;
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.
*/
pluginRunner?: PluginRunner | { getRuntimeById?(id: string): unknown };
}
export interface ResolvePrConflictsResult {
@@ -144,14 +149,19 @@ async function runResolutionAgent(params: {
conflictedFiles: string[];
settings: Settings;
store: TaskStore;
pluginRunner?: PluginRunner | { getRuntimeById?(id: string): unknown };
}): Promise<void> {
const { cwd, taskId, conflictedFiles, settings, store } = params;
const { cwd, taskId, conflictedFiles, settings, store, pluginRunner } = params;
const sessionModel = getDefaultSessionModel(settings);
/*
* FNXC:McpConfig 2026-06-26-00:00:
* Create-PR conflict resolution is a merger-purpose coding-agent lane; forward configured MCP servers from the scoped task store so PR conflict work sees the same operator-approved tools as other merger surfaces.
*/
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).
*/
const { session } = await createResolvedAgentSession({
cwd,
systemPrompt: SESSION_PROMPT,
@@ -163,6 +173,7 @@ async function runResolutionAgent(params: {
fallbackModelId: settings.fallbackModelId,
settings,
mcpServers,
pluginRunner: pluginRunner as PluginRunner | undefined,
});
try {
@@ -221,6 +232,7 @@ export async function resolvePrConflicts(input: ResolvePrConflictsInput): Promis
conflictedFiles,
settings: input.settings,
store,
pluginRunner: input.pluginRunner,
});
const unresolvedFiles = await findFilesWithConflictMarkers(cwd, conflictedFiles);

View File

@@ -2475,7 +2475,7 @@ export async function refreshIssueInBackground(
}
export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError, store } = ctx;
const { router, getProjectContext, rethrowAsApiError, store, options } = ctx;
/*
FNXC:Workspace 2026-06-24-21:00:
@@ -5136,7 +5136,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
*/
router.post("/tasks/:id/pr/resolve-conflicts", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (task.column !== "in-review") {
throw badRequest("Task must be in 'in-review' column to resolve PR conflicts");
@@ -5164,12 +5164,23 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch");
const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch);
/*
FNXC:GrokCliRouting 2026-07-15-09:58:
Create-PR conflict resolution must forward a real PluginRunner (getRuntimeById) so grok-cli/no-key sessions resolve the Grok CLI runtime. Prefer the project engine runner (same pattern as resolveChatManagerPluginRunner); never pass a bare PluginLoader which lacks getRuntimeById. Fall back to options.pluginRunner only when it actually exposes getRuntimeById (UI-only may only have the loader — omit in that case so dual-remediation surfaces cleanly).
*/
const engineRunner = engine?.getPluginRunner?.();
const optionsRunner = options?.pluginRunner;
const pluginRunner =
engineRunner
?? (typeof optionsRunner?.getRuntimeById === "function" ? optionsRunner : undefined);
const result = await resolvePrConflicts({
taskId: task.id,
baseRef,
rootDir: repoRoot,
store: scopedStore,
settings: await scopedStore.getSettings(),
pluginRunner,
});
if (!result.resolved) {