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:quick-fix": { "plan-review": false, "code-review": false, "browser-verification": false },
|
||||||
"builtin:review-heavy": { "plan-review": true, "code-review": true, "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: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 },
|
"builtin:stepwise-coding": { "plan-review": true, "code-review": true, "browser-verification": false },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -773,8 +773,6 @@ describe("built-in workflows", () => {
|
|||||||
const expectedPrompts = new Map([
|
const expectedPrompts = new Map([
|
||||||
["plan", "/ce-plan"],
|
["plan", "/ce-plan"],
|
||||||
["execute", "/ce-work"],
|
["execute", "/ce-work"],
|
||||||
["commit-pr", "/ce-commit-push-pr"],
|
|
||||||
["resolve-feedback", "/ce-resolve-pr-feedback"],
|
|
||||||
["document", "/ce-compound"],
|
["document", "/ce-compound"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -785,25 +783,35 @@ describe("built-in workflows", () => {
|
|||||||
expect(String(docReviewTemplate?.nodes?.[0]?.config?.prompt ?? "")).toContain("/ce-doc-review");
|
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;
|
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");
|
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-");
|
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 ce = getBuiltinWorkflow("builtin:compound-engineering")!;
|
||||||
const byId = (id: string) => ce.ir.nodes.find((n) => n.id === id);
|
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");
|
const manualPr = byId("manual-pr-review");
|
||||||
expect(byId("commit-pr")?.config?.toolMode).toBe("coding");
|
expect(manualPr?.kind).toBe("optional-group");
|
||||||
expect(byId("resolve-feedback")?.config?.skillName).toBe("compound-engineering:ce-resolve-pr-feedback");
|
expect(manualPr?.column).toBe("in-review");
|
||||||
expect(String(byId("commit-pr")?.config?.prompt ?? "")).toContain("When project autoMerge is off");
|
expect(manualPr?.config?.defaultOn).toBe(false);
|
||||||
expect(String(byId("commit-pr")?.config?.prompt ?? "")).toContain("do not perform the Fusion board-state merge");
|
expect(manualPr?.config?.requiresAutoMergeOff).toBe(true);
|
||||||
// KTD-6: the Fusion board-merge seam is preserved (CE prepares the PR, Fusion
|
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;
|
||||||
// owns the merge transition). With autoMerge:false, the runtime seam no-ops
|
expect(template?.nodes?.map((node) => [node.id, node.kind])).toEqual([
|
||||||
// into manual review; the CE PR skills are still ordered before this seam.
|
["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");
|
expect(byId("merge")?.config?.seam).toBe("merge");
|
||||||
// Ordering: commit-pr → resolve-feedback → merge → document.
|
|
||||||
const ids = ce.ir.nodes.map((n) => n.id);
|
const ids = ce.ir.nodes.map((n) => n.id);
|
||||||
expect(ids.indexOf("commit-pr")).toBeLessThan(ids.indexOf("resolve-feedback"));
|
expect(ids.indexOf("review-handoff")).toBeLessThan(ids.indexOf("manual-pr-review"));
|
||||||
expect(ids.indexOf("resolve-feedback")).toBeLessThan(ids.indexOf("merge"));
|
expect(ids.indexOf("manual-pr-review")).toBeLessThan(ids.indexOf("merge"));
|
||||||
expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("document"));
|
expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("document"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -818,8 +826,8 @@ describe("built-in workflows", () => {
|
|||||||
"execute",
|
"execute",
|
||||||
"browser-verification",
|
"browser-verification",
|
||||||
"code-review",
|
"code-review",
|
||||||
"commit-pr",
|
"review-handoff",
|
||||||
"resolve-feedback",
|
"manual-pr-review",
|
||||||
"completion-summary",
|
"completion-summary",
|
||||||
"merge",
|
"merge",
|
||||||
"post-merge-verification",
|
"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 === "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 === "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 === "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", () => {
|
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 {
|
export function isBuiltinWorkflowEnabled(id: string, enabledIds?: readonly string[]): boolean {
|
||||||
if (!isBuiltinWorkflowId(id)) return true;
|
if (!isBuiltinWorkflowId(id)) return true;
|
||||||
if (!enabledIds) return true;
|
if (!enabledIds) return true;
|
||||||
@@ -470,38 +520,11 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
|
|||||||
browserVerificationOptionalGroupNode("in-progress"),
|
browserVerificationOptionalGroupNode("in-progress"),
|
||||||
ceCodeReviewOptionalGroupNode("in-progress"),
|
ceCodeReviewOptionalGroupNode("in-progress"),
|
||||||
{
|
{
|
||||||
id: "commit-pr",
|
id: "review-handoff",
|
||||||
kind: "prompt",
|
kind: "prompt",
|
||||||
config: {
|
config: { seam: "review-handoff", name: "Review handoff", prompt: "" },
|
||||||
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.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
ceManualPrReviewOptionalGroupNode("in-review"),
|
||||||
{ id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") },
|
{ id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") },
|
||||||
{
|
{
|
||||||
id: "document",
|
id: "document",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
createMockStore,
|
createMockStore,
|
||||||
mockedCreateFnAgent,
|
mockedCreateFnAgent,
|
||||||
mockedExecSync,
|
mockedExecSync,
|
||||||
|
mockedExistsSync,
|
||||||
resetExecutorMocks,
|
resetExecutorMocks,
|
||||||
} from "./executor-test-helpers.js";
|
} from "./executor-test-helpers.js";
|
||||||
|
|
||||||
@@ -283,6 +284,79 @@ describe("CE workflow-step executor integration", () => {
|
|||||||
expect(live.worktree).toBe("/tmp/test/.worktrees/swift-falcon");
|
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 () => {
|
it("finalizes a merge-confirmed workflow graph task that is stranded before done", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
let live = baseStepTask({
|
let live = baseStepTask({
|
||||||
@@ -410,6 +484,115 @@ describe("CE workflow-step executor integration", () => {
|
|||||||
expect(live.column).toBe("in-review");
|
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 () => {
|
it("clears stale workflow input markers when a resumed graph restarts before the original node", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
let live = baseStepTask({
|
let live = baseStepTask({
|
||||||
@@ -569,8 +752,7 @@ describe("CE workflow-step executor integration", () => {
|
|||||||
"compound-engineering:ce-doc-review",
|
"compound-engineering:ce-doc-review",
|
||||||
"compound-engineering:ce-work",
|
"compound-engineering:ce-work",
|
||||||
"compound-engineering:ce-code-review",
|
"compound-engineering:ce-code-review",
|
||||||
"compound-engineering:ce-commit-push-pr",
|
"compound-engineering:ce-commit",
|
||||||
"compound-engineering:ce-resolve-pr-feedback",
|
|
||||||
"compound-engineering:ce-compound",
|
"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 () => {
|
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 handlers = createPrNodeHandlers(deps());
|
||||||
const result = await handlers["pr-create"](NODE, ctx());
|
const result = await handlers["pr-create"](NODE, ctx());
|
||||||
expect(result).toEqual({ outcome: "success", value: "open" });
|
expect(result).toEqual({ outcome: "success", value: "open" });
|
||||||
@@ -85,6 +86,14 @@ describe("PR node handlers (U3)", () => {
|
|||||||
expect(entity?.prNumber).toBe(42);
|
expect(entity?.prNumber).toBe(42);
|
||||||
expect(entity?.prUrl).toBe("https://github.com/owner/repo/pull/42");
|
expect(entity?.prUrl).toBe("https://github.com/owner/repo/pull/42");
|
||||||
expect(entity?.headOid).toBe("abc123");
|
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 () => {
|
it("pr-create failure → entity failed + failureReason, value:failed (routable, never throws)", async () => {
|
||||||
|
|||||||
@@ -5879,7 +5879,7 @@ export class TaskExecutor {
|
|||||||
task: TaskDetail,
|
task: TaskDetail,
|
||||||
metadata: { reason: string; nodeId: string; workflowId: string; runId: string },
|
metadata: { reason: string; nodeId: string; workflowId: string; runId: string },
|
||||||
): Promise<TaskDetail> {
|
): Promise<TaskDetail> {
|
||||||
const live = await this.store.getTask(task.id);
|
let live = await this.store.getTask(task.id);
|
||||||
if (!live) return task;
|
if (!live) return task;
|
||||||
if (live.column === "in-review" || live.column === "done") return live;
|
if (live.column === "in-review" || live.column === "done") return live;
|
||||||
if (live.paused || live.userPaused) return live;
|
if (live.paused || live.userPaused) return live;
|
||||||
@@ -5887,7 +5887,32 @@ export class TaskExecutor {
|
|||||||
/*
|
/*
|
||||||
FNXC:WorkflowMerge 2026-06-29-10:15:
|
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.
|
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 = {
|
const moveOptions = {
|
||||||
preserveProgress: true,
|
preserveProgress: true,
|
||||||
moveSource: "engine" as const,
|
moveSource: "engine" as const,
|
||||||
@@ -5907,6 +5932,18 @@ export class TaskExecutor {
|
|||||||
return { ...live, column: "in-review" };
|
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 {
|
public createAuthoritativeWorkflowSeams(_settings: Settings): WorkflowLegacySeams {
|
||||||
return {
|
return {
|
||||||
// Built-in triage/spec generation runs upstream of the interpreter today,
|
// 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");
|
await this.handoffTaskToReview(live, "workflow-graph-review");
|
||||||
return { outcome: "success", value: "in-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) => {
|
merge: async (seamTask) => {
|
||||||
if (!this.mergeRequester) {
|
if (!this.mergeRequester) {
|
||||||
return { outcome: "failure", value: "merge-unavailable" };
|
return { outcome: "failure", value: "merge-unavailable" };
|
||||||
@@ -6655,12 +6702,27 @@ export class TaskExecutor {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!requirement.requiresWorktree) return;
|
if (!requirement.requiresWorktree) return;
|
||||||
const live = await this.store.getTask(nodeTask.id);
|
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:
|
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.
|
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> {
|
private async finalizeMergeConfirmedWorkflowGraphTask(taskId: string, reason: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type PrEntity,
|
type PrEntity,
|
||||||
type PrEntityCreateInput,
|
type PrEntityCreateInput,
|
||||||
type PrEntityUpdate,
|
type PrEntityUpdate,
|
||||||
|
type PrInfo,
|
||||||
type TaskDetail,
|
type TaskDetail,
|
||||||
type WorkflowIrNode,
|
type WorkflowIrNode,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
@@ -46,6 +47,7 @@ export interface PrNodeStore extends PrResponseRunStore {
|
|||||||
getPrEntity(id: string): PrEntity | null;
|
getPrEntity(id: string): PrEntity | null;
|
||||||
getActivePrEntityBySource(sourceType: PrEntity["sourceType"], sourceId: string): PrEntity | null;
|
getActivePrEntityBySource(sourceType: PrEntity["sourceType"], sourceId: string): PrEntity | null;
|
||||||
updatePrEntity(id: string, patch: PrEntityUpdate): PrEntity;
|
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,
|
prUrl: created.prUrl,
|
||||||
headOid: created.headOid ?? null,
|
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" };
|
return { outcome: "success", value: "open" };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export class WorkflowGraphExecutor {
|
|||||||
|
|
||||||
public async run(
|
public async run(
|
||||||
task: TaskDetail,
|
task: TaskDetail,
|
||||||
settings: Pick<Settings, "experimentalFeatures"> | undefined,
|
settings: (Pick<Settings, "experimentalFeatures"> & Partial<Pick<Settings, "autoMerge">>) | undefined,
|
||||||
ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR,
|
ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR,
|
||||||
): Promise<WorkflowGraphExecutorResult> {
|
): Promise<WorkflowGraphExecutorResult> {
|
||||||
const startNode = ir.nodes.find((node) => node.kind === "start");
|
const startNode = ir.nodes.find((node) => node.kind === "start");
|
||||||
@@ -598,7 +598,13 @@ export class WorkflowGraphExecutor {
|
|||||||
const enabled = Array.isArray(task.enabledWorkflowSteps)
|
const enabled = Array.isArray(task.enabledWorkflowSteps)
|
||||||
? task.enabledWorkflowSteps.includes(node.id)
|
? task.enabledWorkflowSteps.includes(node.id)
|
||||||
: node.config?.defaultOn === true;
|
: 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
|
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: record the group's own
|
||||||
// outcome on bypass too (mirrors the enabled path + every other node
|
// outcome on bypass too (mirrors the enabled path + every other node
|
||||||
// kind), so a downstream node reading `node:<id>:outcome` from context
|
// kind), so a downstream node reading `node:<id>:outcome` from context
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type WorkflowSeamName =
|
|||||||
| "planning"
|
| "planning"
|
||||||
| "execute"
|
| "execute"
|
||||||
| "review"
|
| "review"
|
||||||
|
| "review-handoff"
|
||||||
| "merge"
|
| "merge"
|
||||||
| "schedule"
|
| "schedule"
|
||||||
| "step-execute";
|
| "step-execute";
|
||||||
@@ -33,6 +34,7 @@ export interface WorkflowLegacySeams {
|
|||||||
planning: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
planning: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||||
execute: (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: (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>;
|
merge: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||||
schedule: (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 === "planning" ||
|
||||||
seam === "execute" ||
|
seam === "execute" ||
|
||||||
seam === "review" ||
|
seam === "review" ||
|
||||||
|
seam === "review-handoff" ||
|
||||||
seam === "merge" ||
|
seam === "merge" ||
|
||||||
seam === "schedule" ||
|
seam === "schedule" ||
|
||||||
seam === "step-execute"
|
seam === "step-execute"
|
||||||
@@ -355,6 +358,15 @@ export function createPrimitivePromptLikeHandler(
|
|||||||
const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" });
|
const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" });
|
||||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
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") {
|
if (seam === "merge") {
|
||||||
const result = await primitives.requestMerge(primitiveCtx, context.task);
|
const result = await primitives.requestMerge(primitiveCtx, context.task);
|
||||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||||
@@ -960,6 +972,7 @@ export function createNoopLegacySeams(): WorkflowLegacySeams {
|
|||||||
execute: success,
|
execute: success,
|
||||||
// U4 (KTD-2): no `workflow-step` seam — workflow gates run as graph nodes.
|
// U4 (KTD-2): no `workflow-step` seam — workflow gates run as graph nodes.
|
||||||
review: success,
|
review: success,
|
||||||
|
"review-handoff": success,
|
||||||
merge: success,
|
merge: success,
|
||||||
schedule: success,
|
schedule: success,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -75,9 +75,8 @@ describe("built-in Compound Engineering workflow skill alignment", () => {
|
|||||||
["plan", "compound-engineering:ce-plan"],
|
["plan", "compound-engineering:ce-plan"],
|
||||||
["ce-doc-review > ce-doc-review-step", "compound-engineering:ce-doc-review"],
|
["ce-doc-review > ce-doc-review-step", "compound-engineering:ce-doc-review"],
|
||||||
["execute", "compound-engineering:ce-work"],
|
["execute", "compound-engineering:ce-work"],
|
||||||
["code-review", "compound-engineering:ce-code-review"],
|
["code-review > code-review-step", "compound-engineering:ce-code-review"],
|
||||||
["commit-pr", "compound-engineering:ce-commit-push-pr"],
|
["manual-pr-review > commit", "compound-engineering:ce-commit"],
|
||||||
["resolve-feedback", "compound-engineering:ce-resolve-pr-feedback"],
|
|
||||||
["document", "compound-engineering:ce-compound"],
|
["document", "compound-engineering:ce-compound"],
|
||||||
]);
|
]);
|
||||||
expect(workflowBareSkillIds).toEqual(expect.arrayContaining(["ce-plan", "ce-work", "ce-code-review"]));
|
expect(workflowBareSkillIds).toEqual(expect.arrayContaining(["ce-plan", "ce-work", "ce-code-review"]));
|
||||||
|
|||||||
Reference in New Issue
Block a user