fix(FN-7219): stabilize compound engineering workflow
This commit is contained in:
7
.changeset/fn-7219-compound-workflow-recovery.md
Normal file
7
.changeset/fn-7219-compound-workflow-recovery.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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" };
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"]));
|
||||
|
||||
Reference in New Issue
Block a user