diff --git a/packages/cli/src/commands/__tests__/grok-runtime-bootstrap.test.ts b/packages/cli/src/commands/__tests__/grok-runtime-bootstrap.test.ts index 9fa758039a..f05edb6dae 100644 --- a/packages/cli/src/commands/__tests__/grok-runtime-bootstrap.test.ts +++ b/packages/cli/src/commands/__tests__/grok-runtime-bootstrap.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index cf356eb5e6..f2b112cb0c 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -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(); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index f1b0d29720..ec74bf79a3 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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(); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 2513719652..4e27ebcb7e 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -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(); diff --git a/packages/dashboard/src/__tests__/pr-conflict-resolver.test.ts b/packages/dashboard/src/__tests__/pr-conflict-resolver.test.ts index a6ec5192f5..ef2639b6ab 100644 --- a/packages/dashboard/src/__tests__/pr-conflict-resolver.test.ts +++ b/packages/dashboard/src/__tests__/pr-conflict-resolver.test.ts @@ -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 { 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"); + }); }); diff --git a/packages/dashboard/src/pr-conflict-resolver.ts b/packages/dashboard/src/pr-conflict-resolver.ts index c2e02e3f0c..079838df9d 100644 --- a/packages/dashboard/src/pr-conflict-resolver.ts +++ b/packages/dashboard/src/pr-conflict-resolver.ts @@ -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 { - 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); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 18c2be3637..100a1dcdd6 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -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) {