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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user