diff --git a/.changeset/fn-7219-compound-workflow-recovery.md b/.changeset/fn-7219-compound-workflow-recovery.md new file mode 100644 index 0000000000..7f39581851 --- /dev/null +++ b/.changeset/fn-7219-compound-workflow-recovery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Compound Engineering tasks running through checkout recovery, PR review policy, and merge handoff. +category: fix +dev: Graph-native workflow nodes reacquire missing worktrees, gate manual PR review on auto-merge off, link PRs to tasks, and project successful node progress at merge. diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index dc8d4f2548..179b6bc64d 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -80,7 +80,7 @@ describe("built-in workflows", () => { "builtin:quick-fix": { "plan-review": false, "code-review": false, "browser-verification": false }, "builtin:review-heavy": { "plan-review": true, "code-review": true, "browser-verification": false }, "builtin:design": { "plan-review": true, "code-review": true, "browser-verification": false }, - "builtin:compound-engineering": { "plan-review": true, "code-review": true, "browser-verification": false }, + "builtin:compound-engineering": { "plan-review": true, "code-review": true, "browser-verification": false, "manual-pr-review": false }, "builtin:stepwise-coding": { "plan-review": true, "code-review": true, "browser-verification": false }, }; @@ -773,8 +773,6 @@ describe("built-in workflows", () => { const expectedPrompts = new Map([ ["plan", "/ce-plan"], ["execute", "/ce-work"], - ["commit-pr", "/ce-commit-push-pr"], - ["resolve-feedback", "/ce-resolve-pr-feedback"], ["document", "/ce-compound"], ]); @@ -785,25 +783,35 @@ describe("built-in workflows", () => { expect(String(docReviewTemplate?.nodes?.[0]?.config?.prompt ?? "")).toContain("/ce-doc-review"); const codeReviewTemplate = byId("code-review")?.config?.template as { nodes?: Array<{ config?: Record }> } | undefined; expect(String(codeReviewTemplate?.nodes?.[0]?.config?.prompt ?? "")).toContain("/ce-code-review"); + const manualPrTemplate = byId("manual-pr-review")?.config?.template as { nodes?: Array<{ config?: Record }> } | undefined; + expect(String(manualPrTemplate?.nodes?.[0]?.config?.prompt ?? "")).toContain("/ce-commit"); expect(String(byId("merge")?.config?.prompt ?? "")).not.toContain("/ce-"); }); - it("compound-engineering merge stage uses the CE commit/PR + resolve-feedback skills", () => { + it("compound-engineering manual PR lane is selected-only, auto-merge-off-only, and uses Fusion PR nodes", () => { const ce = getBuiltinWorkflow("builtin:compound-engineering")!; const byId = (id: string) => ce.ir.nodes.find((n) => n.id === id); - expect(byId("commit-pr")?.config?.skillName).toBe("compound-engineering:ce-commit-push-pr"); - expect(byId("commit-pr")?.config?.toolMode).toBe("coding"); - expect(byId("resolve-feedback")?.config?.skillName).toBe("compound-engineering:ce-resolve-pr-feedback"); - expect(String(byId("commit-pr")?.config?.prompt ?? "")).toContain("When project autoMerge is off"); - expect(String(byId("commit-pr")?.config?.prompt ?? "")).toContain("do not perform the Fusion board-state merge"); - // KTD-6: the Fusion board-merge seam is preserved (CE prepares the PR, Fusion - // owns the merge transition). With autoMerge:false, the runtime seam no-ops - // into manual review; the CE PR skills are still ordered before this seam. + const manualPr = byId("manual-pr-review"); + expect(manualPr?.kind).toBe("optional-group"); + expect(manualPr?.column).toBe("in-review"); + expect(manualPr?.config?.defaultOn).toBe(false); + expect(manualPr?.config?.requiresAutoMergeOff).toBe(true); + const template = manualPr?.config?.template as { nodes?: Array<{ id: string; kind: string; config?: Record }>; edges?: Array<{ from: string; to: string; condition?: string }> } | undefined; + expect(template?.nodes?.map((node) => [node.id, node.kind])).toEqual([ + ["commit", "prompt"], + ["open-pr", "pr-create"], + ["resolve-feedback", "pr-respond"], + ]); + expect(template?.nodes?.[0]?.config?.skillName).toBe("compound-engineering:ce-commit"); + expect(template?.edges).toEqual([ + { from: "commit", to: "open-pr", condition: "success" }, + { from: "open-pr", to: "resolve-feedback", condition: "success" }, + ]); + expect(byId("review-handoff")?.config?.seam).toBe("review-handoff"); expect(byId("merge")?.config?.seam).toBe("merge"); - // Ordering: commit-pr → resolve-feedback → merge → document. const ids = ce.ir.nodes.map((n) => n.id); - expect(ids.indexOf("commit-pr")).toBeLessThan(ids.indexOf("resolve-feedback")); - expect(ids.indexOf("resolve-feedback")).toBeLessThan(ids.indexOf("merge")); + expect(ids.indexOf("review-handoff")).toBeLessThan(ids.indexOf("manual-pr-review")); + expect(ids.indexOf("manual-pr-review")).toBeLessThan(ids.indexOf("merge")); expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("document")); }); @@ -818,8 +826,8 @@ describe("built-in workflows", () => { "execute", "browser-verification", "code-review", - "commit-pr", - "resolve-feedback", + "review-handoff", + "manual-pr-review", "completion-summary", "merge", "post-merge-verification", @@ -870,7 +878,9 @@ describe("built-in workflows", () => { expect(ce.ir.edges.some((edge) => edge.from === "plan-review" && edge.to === "execute")).toBe(true); expect(ce.ir.edges.some((edge) => edge.from === "execute" && edge.to === "browser-verification")).toBe(true); expect(ce.ir.edges.some((edge) => edge.from === "browser-verification" && edge.to === "code-review")).toBe(true); - expect(ce.ir.edges.some((edge) => edge.from === "code-review" && edge.to === "commit-pr")).toBe(true); + expect(ce.ir.edges.some((edge) => edge.from === "code-review" && edge.to === "review-handoff")).toBe(true); + expect(ce.ir.edges.some((edge) => edge.from === "review-handoff" && edge.to === "manual-pr-review")).toBe(true); + expect(ce.ir.edges.some((edge) => edge.from === "manual-pr-review" && edge.to === "completion-summary")).toBe(true); }); it("non-default coding built-ins retain their generic review nodes", () => { diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index a32a1782eb..6b696fa7d2 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -85,6 +85,56 @@ function ceCodeReviewOptionalGroupNode(column: string): WorkflowIrNode { }; } +function ceManualPrReviewOptionalGroupNode(column: string): WorkflowIrNode { + return { + id: "manual-pr-review", + kind: "optional-group", + column, + config: { + /* + * FNXC:WorkflowPrPolicy 2026-06-29-16:42: + * Compound Engineering's PR path is manual-review policy, not the default delivery path. Keep it default-off and require effective auto-merge to be off; when enabled, CE commits the work while Fusion PR nodes open/link the PR and own feedback response so the dashboard, PR monitor, and merge gates see first-class PR state. + */ + name: "Manual PR Review", + defaultOn: false, + requiresAutoMergeOff: true, + template: { + nodes: [ + { + id: "commit", + kind: "prompt", + config: { + name: "Commit", + executor: "skill", + skillName: "compound-engineering:ce-commit", + toolMode: "coding", + prompt: "Run /ce-commit to commit the completed work in logical commits. Do not push or open a pull request; the following Fusion PR node owns PR creation and dashboard linkage.", + }, + }, + { + id: "open-pr", + kind: "pr-create", + config: { + name: "Open PR", + }, + }, + { + id: "resolve-feedback", + kind: "pr-respond", + config: { + name: "Resolve PR feedback", + }, + }, + ], + edges: [ + { from: "commit", to: "open-pr", condition: "success" }, + { from: "open-pr", to: "resolve-feedback", condition: "success" }, + ], + }, + }, + }; +} + export function isBuiltinWorkflowEnabled(id: string, enabledIds?: readonly string[]): boolean { if (!isBuiltinWorkflowId(id)) return true; if (!enabledIds) return true; @@ -470,38 +520,11 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ browserVerificationOptionalGroupNode("in-progress"), ceCodeReviewOptionalGroupNode("in-progress"), { - id: "commit-pr", + id: "review-handoff", kind: "prompt", - config: { - name: "Commit & open PR", - executor: "skill", - skillName: "compound-engineering:ce-commit-push-pr", - // Coding mode: this step runs git + gh. Per KTD-6 it OWNS commit / - // push / PR creation; it does NOT perform the board-state merge — that - // stays with Fusion's merge seam below (workflow-owned merge), so the - // two never race the same branch state. - /* - * FNXC:Workflows 2026-06-27-00:00: - * FN-7144 confirms the autoMerge-off CE route: ce-commit-push-pr and ce-resolve-pr-feedback prepare the human PR flow, while the later Fusion merge seam no-ops into manual review instead of forcing an unattended board merge. - */ - toolMode: "coding", - prompt: "Run /ce-commit-push-pr to commit the work in logical commits, push the branch, and open a pull request with a value-first description. When project autoMerge is off, this PR is the human merge/review path; do not perform the Fusion board-state merge here.", - }, - }, - { - id: "resolve-feedback", - kind: "prompt", - config: { - name: "Resolve PR feedback", - executor: "skill", - skillName: "compound-engineering:ce-resolve-pr-feedback", - toolMode: "coding", - // Resolves open PR review threads. On the first autonomous pass there - // may be no feedback yet (review is async); the skill no-ops when there - // are no threads, and a re-run picks up later feedback. - prompt: "Run /ce-resolve-pr-feedback to resolve open PR review feedback: evaluate each thread, fix valid issues, and reply.", - }, + config: { seam: "review-handoff", name: "Review handoff", prompt: "" }, }, + ceManualPrReviewOptionalGroupNode("in-review"), { id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") }, { id: "document", diff --git a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts index 69a5c1f8f4..774bf354b9 100644 --- a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts +++ b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts @@ -32,6 +32,7 @@ import { createMockStore, mockedCreateFnAgent, mockedExecSync, + mockedExistsSync, resetExecutorMocks, } from "./executor-test-helpers.js"; @@ -283,6 +284,79 @@ describe("CE workflow-step executor integration", () => { expect(live.worktree).toBe("/tmp/test/.worktrees/swift-falcon"); }); + it("reacquires a task worktree when a CE graph node finds a stale missing checkout", async () => { + const store = createMockStore(); + mockedExistsSync.mockImplementation((path) => path !== "/tmp/test/.worktrees/missing-ce-checkout"); + let live = baseStepTask({ + worktree: "/tmp/test/.worktrees/missing-ce-checkout", + branch: "fusion/fn-ce-1", + steps: [{ name: "Preflight", status: "pending" }], + }); + store.getTask.mockImplementation(async () => live as any); + store.updateTask.mockImplementation(async (_id: string, patch: Record) => { + live = { ...live, ...patch }; + return live as any; + }); + const { executor } = makeExecutor(store); + vi.spyOn(executor as any, "createWorktree").mockResolvedValue({ + path: "/tmp/test/.worktrees/fresh-ce-checkout", + branch: "fusion/fn-ce-1", + }); + vi.spyOn(executor as any, "captureBaseCommitSha").mockResolvedValue(undefined); + + const captured: { worktreePath?: string } = {}; + vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => { + captured.worktreePath = args[2]; + return { success: true, output: "ok" }; + }); + + const node = { + id: "plan", + kind: "prompt", + column: "in-progress", + config: { + executor: "skill", + skillName: "compound-engineering:ce-plan", + toolMode: "coding", + prompt: "Run /ce-plan.", + }, + }; + const ir: WorkflowIr = { + version: "v2", + name: "ce-plan-stale-worktree-test", + columns: [{ id: "in-progress", name: "In Progress", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + node as any, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "plan" }, + { from: "plan", to: "end", condition: "success" }, + ], + }; + const settings = await store.getSettings(); + const graph = new WorkflowGraphExecutor({ + prepareNodeExecution: (graphNode, task, requirement) => + (executor as any).prepareGraphNodeExecution(graphNode, task, settings, requirement), + runCustomNode: (graphNode, task, context) => + (executor as any).runGraphCustomNode(graphNode, task, settings, undefined, context), + }); + + const result = await graph.run(live as any, settings, ir); + + expect(result.outcome).toBe("success"); + expect((executor as any).createWorktree).toHaveBeenCalled(); + expect(captured.worktreePath).toBe("/tmp/test/.worktrees/fresh-ce-checkout"); + expect(live.worktree).toBe("/tmp/test/.worktrees/fresh-ce-checkout"); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-CE-1", + "Workflow node 'plan' assigned worktree is missing — reacquiring before node execution", + "/tmp/test/.worktrees/missing-ce-checkout", + undefined, + ); + }); + it("finalizes a merge-confirmed workflow graph task that is stranded before done", async () => { const store = createMockStore(); let live = baseStepTask({ @@ -410,6 +484,115 @@ describe("CE workflow-step executor integration", () => { expect(live.column).toBe("in-review"); }); + it("completes graph-native checklist projection before a workflow merge request", async () => { + const store = createMockStore(); + let live = baseStepTask({ + column: "in-progress", + steps: [ + { name: "Diagnose", status: "pending" }, + { name: "Implement", status: "pending" }, + ], + workflowStepResults: [ + { + workflowStepId: "plan", + workflowStepName: "Plan", + phase: "pre-merge", + source: "node", + status: "passed", + }, + ], + }); + store.getTask.mockImplementation(async () => live as any); + store.updateTask.mockImplementation(async (_id: string, patch: Record) => { + live = { ...live, ...patch }; + return live as any; + }); + store.moveTask.mockImplementation(async (_id: string, column: string) => { + live = { ...live, column }; + return live as any; + }); + const { executor } = makeExecutor(store); + const mergeRequester = vi.fn(async () => ({ + task: live, + branch: "fusion/fn-ce-1", + merged: false, + noOp: false, + reason: "queued", + })); + executor.setMergeRequester(mergeRequester as any); + const settings = await store.getSettings(); + const primitives = (executor as any).createAuthoritativeWorkflowPrimitives(settings); + + await primitives.requestMerge( + { + run: { runId: "run-merge", taskId: "FN-CE-1", workflowId: "builtin:compound-engineering" }, + node: { node: { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } }, context: {} }, + }, + live, + ); + + expect(store.updateTask).toHaveBeenCalledWith( + "FN-CE-1", + expect.objectContaining({ + steps: [ + { name: "Diagnose", status: "done" }, + { name: "Implement", status: "done" }, + ], + currentStep: 1, + }), + undefined, + ); + expect(mergeRequester).toHaveBeenCalledWith("FN-CE-1", expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(live.steps.every((step: any) => step.status === "done")).toBe(true); + expect(live.column).toBe("in-review"); + }); + + it("skips manual PR optional groups while effective auto-merge is on", async () => { + const store = createMockStore(); + const live = baseStepTask({ + autoMerge: true, + enabledWorkflowSteps: ["manual-pr-review"], + }); + store.getTask.mockResolvedValue(live as any); + const { executor } = makeExecutor(store); + const runCustomNode = vi.spyOn(executor as any, "runGraphCustomNode").mockResolvedValue({ outcome: "success", value: "ran" }); + const graph = new WorkflowGraphExecutor({ + runCustomNode: (graphNode, task, context) => + (executor as any).runGraphCustomNode(graphNode, task, {}, undefined, context), + }); + const ir: WorkflowIr = { + version: "v2", + name: "manual-pr-automerge-skip", + columns: [{ id: "in-review", name: "In Review", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { + id: "manual-pr-review", + kind: "optional-group", + column: "in-review", + config: { + defaultOn: false, + requiresAutoMergeOff: true, + template: { + nodes: [{ id: "commit", kind: "prompt", config: { prompt: "commit" } }], + edges: [], + }, + }, + }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "manual-pr-review" }, + { from: "manual-pr-review", to: "end", condition: "success" }, + ], + }; + + const result = await graph.run(live as any, { experimentalFeatures: {}, autoMerge: true }, ir); + + expect(result.outcome).toBe("success"); + expect(runCustomNode).not.toHaveBeenCalled(); + }); + it("clears stale workflow input markers when a resumed graph restarts before the original node", async () => { const store = createMockStore(); let live = baseStepTask({ @@ -569,8 +752,7 @@ describe("CE workflow-step executor integration", () => { "compound-engineering:ce-doc-review", "compound-engineering:ce-work", "compound-engineering:ce-code-review", - "compound-engineering:ce-commit-push-pr", - "compound-engineering:ce-resolve-pr-feedback", + "compound-engineering:ce-commit", "compound-engineering:ce-compound", ]); }); diff --git a/packages/engine/src/__tests__/pr-nodes.test.ts b/packages/engine/src/__tests__/pr-nodes.test.ts index cb765474c1..28aaac90ff 100644 --- a/packages/engine/src/__tests__/pr-nodes.test.ts +++ b/packages/engine/src/__tests__/pr-nodes.test.ts @@ -76,6 +76,7 @@ describe("PR node handlers (U3)", () => { } it("pr-create success → entity open with persisted PR fields, value:open", async () => { + const updatePrInfo = vi.spyOn(store, "updatePrInfo").mockResolvedValue({ id: "T-1" } as any); const handlers = createPrNodeHandlers(deps()); const result = await handlers["pr-create"](NODE, ctx()); expect(result).toEqual({ outcome: "success", value: "open" }); @@ -85,6 +86,14 @@ describe("PR node handlers (U3)", () => { expect(entity?.prNumber).toBe(42); expect(entity?.prUrl).toBe("https://github.com/owner/repo/pull/42"); expect(entity?.headOid).toBe("abc123"); + expect(updatePrInfo).toHaveBeenCalledWith("T-1", expect.objectContaining({ + url: "https://github.com/owner/repo/pull/42", + number: 42, + status: "open", + headBranch: "fusion/t-1", + baseBranch: "main", + manual: true, + })); }); it("pr-create failure → entity failed + failureReason, value:failed (routable, never throws)", async () => { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index ca63b650b9..5c636bcd01 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -5879,7 +5879,7 @@ export class TaskExecutor { task: TaskDetail, metadata: { reason: string; nodeId: string; workflowId: string; runId: string }, ): Promise { - const live = await this.store.getTask(task.id); + let live = await this.store.getTask(task.id); if (!live) return task; if (live.column === "in-review" || live.column === "done") return live; if (live.paused || live.userPaused) return live; @@ -5887,7 +5887,32 @@ export class TaskExecutor { /* FNXC:WorkflowMerge 2026-06-29-10:15: User-authored workflows may legitimately route execution directly to a merge node without an explicit review node. Reaching that node is the workflow-owned merge boundary, so the engine must establish the durable in-review/merge lifecycle handoff before requesting merge instead of assuming a prior node already moved the card. + + FNXC:WorkflowMerge 2026-06-29-15:28: + Compound Engineering and similar graph-native workflows execute skill nodes instead of legacy parsed task steps. The graph records those nodes as `workflowStepResults.source = "node"`; at the merge boundary, project a successful graph-native run onto the legacy checklist so `task has incomplete steps` cannot block a workflow that already completed its authoritative nodes. */ + if (this.shouldCompleteChecklistAtWorkflowMerge(live)) { + const completedSteps = live.steps.map((step) => + step.status === "done" || step.status === "skipped" + ? step + : { ...step, status: "done" as const }, + ); + const updated = await this.store.updateTask( + live.id, + { + steps: completedSteps, + currentStep: Math.max(0, completedSteps.length - 1), + } as Partial, + this.getRunContextFor(live.id), + ); + live = (updated as TaskDetail | undefined) ?? { ...live, steps: completedSteps, currentStep: Math.max(0, completedSteps.length - 1) }; + await this.store.logEntry( + live.id, + "Workflow merge boundary completed graph-native task checklist before requesting merge", + undefined, + this.getRunContextFor(live.id), + ); + } const moveOptions = { preserveProgress: true, moveSource: "engine" as const, @@ -5907,6 +5932,18 @@ export class TaskExecutor { return { ...live, column: "in-review" }; } + private shouldCompleteChecklistAtWorkflowMerge(task: TaskDetail): boolean { + if (!Array.isArray(task.steps) || task.steps.length === 0) return false; + if (task.steps.every((step) => step.status === "done" || step.status === "skipped")) return false; + + const graphNodeResults = (task.workflowStepResults ?? []).filter((result) => + result.source === "node" && (result.phase ?? "pre-merge") === "pre-merge" + ); + if (graphNodeResults.length === 0) return false; + + return graphNodeResults.every((result) => result.status === "passed" || result.status === "skipped"); + } + public createAuthoritativeWorkflowSeams(_settings: Settings): WorkflowLegacySeams { return { // Built-in triage/spec generation runs upstream of the interpreter today, @@ -5964,6 +6001,16 @@ export class TaskExecutor { await this.handoffTaskToReview(live, "workflow-graph-review"); return { outcome: "success", value: "in-review" }; }, + "review-handoff": async (seamTask) => { + /* + * FNXC:WorkflowPrPolicy 2026-06-29-16:42: + * Compound Engineering can run an optional manual PR review lane after implementation. That lane must start from the review column without invoking the generic reviewer again; this seam is a pure lifecycle handoff so PR creation/feedback nodes run while the card is visibly in review. + */ + const live = await this.store.getTask(seamTask.id); + await this.persistTokenUsage(seamTask.id); + await this.handoffTaskToReview(live, "workflow-graph-review-handoff"); + return { outcome: "success", value: "in-review" }; + }, merge: async (seamTask) => { if (!this.mergeRequester) { return { outcome: "failure", value: "merge-unavailable" }; @@ -6655,12 +6702,27 @@ export class TaskExecutor { ): Promise { if (!requirement.requiresWorktree) return; const live = await this.store.getTask(nodeTask.id); - if (live.worktree) return; + if (live.worktree && existsSync(live.worktree)) return; + const taskForAcquisition = live.worktree + ? ({ ...live, worktree: undefined, sessionFile: undefined } as TaskDetail) + : live; + if (live.worktree) { + /* + FNXC:WorkflowExecution 2026-06-29-15:28: + A graph-native skill node such as Compound Engineering `plan` may be the first write-capable node. A stale task row can still point at a removed checkout after reset/retry/self-healing; a truthy `worktree` field is not proof of node readiness. Fall through to fresh acquisition when the directory is missing so the graph starts a session instead of failing immediately at the first CE node. + */ + await this.store.logEntry( + live.id, + `Workflow node '${node.id}' assigned worktree is missing — reacquiring before node execution`, + live.worktree, + this.getRunContextFor(live.id), + ); + } /* FNXC:WorkflowExecution 2026-06-29-09:50: The workflow graph decides which nodes require pre-execution lifecycle resources. This adapter only fulfills a graph-declared worktree requirement with executor-owned git mechanics; custom-node handlers remain ordinary node execution and no longer decide when to bootstrap task isolation. */ - await this.ensureGraphCustomNodeWorktree(live, settings, node.id); + await this.ensureGraphCustomNodeWorktree(taskForAcquisition, settings, node.id); } private async finalizeMergeConfirmedWorkflowGraphTask(taskId: string, reason: string): Promise { diff --git a/packages/engine/src/pr-nodes.ts b/packages/engine/src/pr-nodes.ts index 1fbcac89f1..5f6b799403 100644 --- a/packages/engine/src/pr-nodes.ts +++ b/packages/engine/src/pr-nodes.ts @@ -21,6 +21,7 @@ import { type PrEntity, type PrEntityCreateInput, type PrEntityUpdate, + type PrInfo, type TaskDetail, type WorkflowIrNode, } from "@fusion/core"; @@ -46,6 +47,7 @@ export interface PrNodeStore extends PrResponseRunStore { getPrEntity(id: string): PrEntity | null; getActivePrEntityBySource(sourceType: PrEntity["sourceType"], sourceId: string): PrEntity | null; updatePrEntity(id: string, patch: PrEntityUpdate): PrEntity; + updatePrInfo?(id: string, prInfo: PrInfo | null): Promise; } /** @@ -362,6 +364,24 @@ export function createPrNodeHandlers(deps: PrNodeDeps): Record< prUrl: created.prUrl, headOid: created.headOid ?? null, }); + /* + * FNXC:WorkflowPrPolicy 2026-06-29-16:42: + * PRs opened by workflow PR nodes must become first-class Fusion task state immediately. The dashboard already renders `task.prInfo`/`task.prInfos`; linking the created PR here keeps manual PR review lanes visible on task cards/details and lets PR monitoring attach when the workflow moves into review. + */ + try { + await store.updatePrInfo?.(ctx.task.id, { + url: created.prUrl, + number: created.prNumber, + status: "open", + title: ctx.task.title ?? `Task ${ctx.task.id}`, + headBranch: creating.headBranch, + baseBranch: creating.baseBranch ?? "main", + commentCount: 0, + manual: true, + }); + } catch (err) { + audit("pr-create-task-link-failed", `pr-create node '${node.id}' opened PR but could not link task ${ctx.task.id}: ${classifyError(err)}`); + } return { outcome: "success", value: "open" }; }; diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 22011c7bde..ba98b2f165 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -328,7 +328,7 @@ export class WorkflowGraphExecutor { public async run( task: TaskDetail, - settings: Pick | undefined, + settings: (Pick & Partial>) | undefined, ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR, ): Promise { const startNode = ir.nodes.find((node) => node.kind === "start"); @@ -598,7 +598,13 @@ export class WorkflowGraphExecutor { const enabled = Array.isArray(task.enabledWorkflowSteps) ? task.enabledWorkflowSteps.includes(node.id) : node.config?.defaultOn === true; - if (!enabled) { + const requiresAutoMergeOff = node.config?.requiresAutoMergeOff === true; + const autoMergeOff = task.autoMerge === false || (settings?.autoMerge === false && task.autoMerge !== true); + /* + * FNXC:WorkflowPrPolicy 2026-06-29-16:42: + * Manual PR review lanes are operator-selected workflow branches, not the default CE/automerge path. An optional-group with `requiresAutoMergeOff` is inert unless the task explicitly enables the group and effective auto-merge is off, so selected manual PR creation cannot hijack the normal Fusion auto-merge route. + */ + if (!enabled || (requiresAutoMergeOff && !autoMergeOff)) { // FNXC:WorkflowOptionalGroup 2026-06-21-16:30: record the group's own // outcome on bypass too (mirrors the enabled path + every other node // kind), so a downstream node reading `node::outcome` from context diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index 869155e7b3..b5c98c4d92 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -22,6 +22,7 @@ export type WorkflowSeamName = | "planning" | "execute" | "review" + | "review-handoff" | "merge" | "schedule" | "step-execute"; @@ -33,6 +34,7 @@ export interface WorkflowLegacySeams { planning: (task: TaskDetail, context: Record) => Promise; execute: (task: TaskDetail, context: Record) => Promise; review: (task: TaskDetail, context: Record) => Promise; + "review-handoff"?: (task: TaskDetail, context: Record) => Promise; merge: (task: TaskDetail, context: Record) => Promise; schedule: (task: TaskDetail, context: Record) => Promise; /** @@ -211,6 +213,7 @@ export function resolveSeamName(node: { config?: Record }): Wor seam === "planning" || seam === "execute" || seam === "review" || + seam === "review-handoff" || seam === "merge" || seam === "schedule" || seam === "step-execute" @@ -355,6 +358,15 @@ export function createPrimitivePromptLikeHandler( const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" }); return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch }; } + if (seam === "review-handoff") { + const result = await primitives.transitionTask(primitiveCtx, context.task, { + column: "in-review", + status: null, + reason: "workflow-review-handoff", + preserveProgress: true, + }); + return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch }; + } if (seam === "merge") { const result = await primitives.requestMerge(primitiveCtx, context.task); return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch }; @@ -960,6 +972,7 @@ export function createNoopLegacySeams(): WorkflowLegacySeams { execute: success, // U4 (KTD-2): no `workflow-step` seam — workflow gates run as graph nodes. review: success, + "review-handoff": success, merge: success, schedule: success, }; diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/ce-workflow-skill-alignment.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/ce-workflow-skill-alignment.test.ts index a1c1a57a25..9eea0e586e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/ce-workflow-skill-alignment.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/ce-workflow-skill-alignment.test.ts @@ -75,9 +75,8 @@ describe("built-in Compound Engineering workflow skill alignment", () => { ["plan", "compound-engineering:ce-plan"], ["ce-doc-review > ce-doc-review-step", "compound-engineering:ce-doc-review"], ["execute", "compound-engineering:ce-work"], - ["code-review", "compound-engineering:ce-code-review"], - ["commit-pr", "compound-engineering:ce-commit-push-pr"], - ["resolve-feedback", "compound-engineering:ce-resolve-pr-feedback"], + ["code-review > code-review-step", "compound-engineering:ce-code-review"], + ["manual-pr-review > commit", "compound-engineering:ce-commit"], ["document", "compound-engineering:ce-compound"], ]); expect(workflowBareSkillIds).toEqual(expect.arrayContaining(["ce-plan", "ce-work", "ce-code-review"]));