fix(FN-7219): stabilize compound engineering workflow

This commit is contained in:
gsxdsm
2026-06-29 16:42:11 -07:00
parent 42226edde1
commit d7f26bb6cf
10 changed files with 389 additions and 58 deletions

View File

@@ -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.

View File

@@ -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<string, unknown> }> } | 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<string, unknown> }> } | 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<string, unknown> }>; 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", () => {

View File

@@ -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",

View File

@@ -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<string, unknown>) => {
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<string, unknown>) => {
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",
]);
});

View File

@@ -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 () => {

View File

@@ -5879,7 +5879,7 @@ export class TaskExecutor {
task: TaskDetail,
metadata: { reason: string; nodeId: string; workflowId: string; runId: string },
): Promise<TaskDetail> {
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<TaskDetail>,
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<void> {
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<boolean> {

View File

@@ -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<unknown>;
}
/**
@@ -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" };
};

View File

@@ -328,7 +328,7 @@ export class WorkflowGraphExecutor {
public async run(
task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined,
settings: (Pick<Settings, "experimentalFeatures"> & Partial<Pick<Settings, "autoMerge">>) | undefined,
ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR,
): Promise<WorkflowGraphExecutorResult> {
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:<id>:outcome` from context

View File

@@ -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<string, unknown>) => Promise<WorkflowNodeResult>;
execute: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
review: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
"review-handoff"?: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
merge: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
schedule: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
/**
@@ -211,6 +213,7 @@ export function resolveSeamName(node: { config?: Record<string, unknown> }): 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,
};

View File

@@ -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"]));