Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-merge-boundary-optional-group-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---

summary: Stop parking tasks at merge-boundary-unproven when their passed pre-merge reviews came from enabled optional steps.
category: fix
dev: The merge-boundary proof now accepts graph-native pre-merge results from both origins (`source="node"` and `source="optional-group"`); phase (`pre-merge`) and terminality plus foreach instance coverage stay mandatory. Shared predicate `isGraphNativePreMergeResult` also backs `shouldCompleteChecklistAtWorkflowMerge`.
8 changes: 8 additions & 0 deletions .changeset/integration-branch-validate-exists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
---

summary: Worktree setup no longer aborts when the configured integration branch is missing locally.
category: fix
dev: resolveIntegrationBranch probes refs/heads via argv-based `git show-ref --verify` (no shell interpolation) and walks the documented ladder `integrationBranch -> baseBranch -> origin/HEAD -> inference -> main`: a set-but-missing integrationBranch no longer hides a valid baseBranch. Configured and inferred branches that exist only under refs/remotes/origin are materialized locally (plain `git branch <name> refs/remotes/origin/<name>`, never --track, matching the readiness path FN-183) before being returned, so consumers like `git worktree add` and the merge-time CAS advance always find refs/heads/<branch>. Every non-settings rung is verified against the local ref; the fallback ladder verifies-or-materializes the origin-derived candidate and then `main`, probes the remote-tracking ref before creating (absent -> falls through), rechecks the local ref after a failed creation to absorb a lost race, rethrows the original write error when the ref is still absent, and throws an actionable error when no local integration ref can be established at all — so no-checkout, detached, or ghost clones can no longer hand consumers a nonexistent bare name. Only show-ref's missing-ref exit status (1) is treated as absent; operational git failures propagate to callers. Branch-name candidates are validated with full git-check-ref-format(1) semantics (pure-JS, no subprocess) at every rung — settings, origin/HEAD, inference — and at every inference return path before any probe or materialization: names git would reject (leading dash, reserved HEAD, spaces, .lock/@{/dot-component rules) fall through the ladder instead of being handed to `git branch` as a bare name or aborting fallback resolution, and a plumbing-created or symbolic ref can no longer pass an existence probe while every consumer command would fail. The origin/HEAD default stays authoritative: when only its remote-tracking ref exists it is materialized locally before local inference runs. Parity with real git is pinned by a real-git integration test.
dev: resolveIntegrationBranch probes refs/heads via argv-based `git show-ref --verify` (no shell interpolation) and walks the documented ladder `integrationBranch -> baseBranch -> origin/HEAD -> inference -> main`: a set-but-missing integrationBranch no longer hides a valid baseBranch. Configured and inferred branches that exist only under refs/remotes/origin are materialized locally with `git branch --no-track <name> refs/remotes/origin/<name>` (matching the readiness path FN-183) before being returned, so consumers like `git worktree add` and the merge-time CAS advance always find refs/heads/<branch> without configuring upstream tracking. Every non-settings rung is verified against the local ref; the fallback ladder verifies-or-materializes the origin-derived candidate and then `main`, probes the remote-tracking ref before creating (absent -> falls through), rechecks the local ref after a failed creation to absorb a lost race, rethrows the original write error when the ref is still absent, and throws an actionable error when no local integration ref can be established at all — so no-checkout, detached, or ghost clones can no longer hand consumers a nonexistent bare name. Only show-ref's missing-ref exit status (1) is treated as absent; operational git failures propagate to callers. Branch-name candidates are validated with full git-check-ref-format(1) semantics (pure-JS, no subprocess) at every rung — settings, origin/HEAD, inference — and at every inference return path before any probe or materialization: names git would reject (leading dash, reserved HEAD, spaces, .lock/@{/dot-component rules) fall through the ladder instead of being handed to `git branch` as a bare name or aborting fallback resolution, and a plumbing-created or symbolic ref can no longer pass an existence probe while every consumer command would fail. The origin/HEAD default stays authoritative: when only its remote-tracking ref exists it is materialized locally before local inference runs. Parity with real git is pinned by a real-git integration test.
161 changes: 159 additions & 2 deletions packages/engine/src/__tests__/executor-graph-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ assertion does not depend on the full agent-session execute() path.
import { describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { evaluateWorkflowMergeBoundary } from "../executor/evaluate-workflow-merge-boundary.js";
import { createMockStore } from "./executor-test-helpers.js";
import type { WorkflowIr } from "@fusion/core";

Expand Down Expand Up @@ -76,8 +77,14 @@ function makeExecutor(opts: {
workflowStepResults?: Array<{
workflowStepId: string;
workflowStepName: string;
source: "node";
phase: "pre-merge";
/*
FNXC:WorkflowMerge 2026-09-19-03:58:
Pre-merge results have two graph-runtime origins: `node` (graph-authored node progress) and
`optional-group` (enabled optional steps such as Plan Review / Code Review). Boundary cases
must be able to construct both, plus a post-merge phase to prove the phase filter still holds.
*/
source: "node" | "optional-group";
phase: "pre-merge" | "post-merge";
status: "passed" | "pending";
completedAt: string;
}>;
Expand Down Expand Up @@ -255,3 +262,153 @@ describe("U5a — IR-driven merge boundary (scenario 1)", () => {
}
});
});

/*
FNXC:WorkflowMerge 2026-09-19-03:58:
Resultados pre-merge podem vir de passos opcionais habilitados (source="optional-group"); a prova de
fronteira deve enxerga-los, senao tarefas com reviews aprovados ficam presas em merge-boundary-unproven.

Pre-merge results have TWO graph-runtime origins: `node` (graph-authored node progress) and
`optional-group` (an enabled optional step, e.g. the builtin Plan Review / Code Review groups).
Measured on a live card (project proj_9ef728e7cc084681): its only two workflowStepResults were
`phase="pre-merge"`, `status="passed"`, `source="optional-group"` (plan-review, code-review) with
enabledWorkflowSteps ["plan-review","code-review"], and the boundary parked it with
"workflow graph terminal merge failure at node 'merge' (merge-boundary-unproven) — operator action required".

The proof must therefore accept BOTH origins without loosening anything else: a non-pre-merge phase
stays out, terminality stays mandatory, and terminal foreach instance coverage stays mandatory.
*/
describe("merge boundary pre-merge result provenance (node | optional-group)", () => {
const optionalGroup = (
workflowStepId: string,
workflowStepName: string,
overrides: Partial<{ phase: "pre-merge" | "post-merge"; status: "passed" | "pending" }> = {},
) => ({
workflowStepId,
workflowStepName,
source: "optional-group" as const,
phase: overrides.phase ?? ("pre-merge" as const),
status: overrides.status ?? ("passed" as const),
completedAt: "2026-09-19T03:58:00.000Z",
});

function boundaryHarness(workflowStepResults: ReturnType<typeof optionalGroup>[], steps: Array<{ id: string; title: string; status: "pending" | "done" | "skipped" }>) {
return makeExecutor({
selection: { workflowId: "custom:foreach", stepIds: [] },
ir: foreachIr(),
taskColumn: "in-progress",
steps,
workflowStepResults,
});
}

it("proves the boundary when the only pre-merge results came from enabled optional groups", async () => {
const { executor, store, liveTask } = boundaryHarness([
optionalGroup("plan-review", "Plan Review"),
optionalGroup("code-review", "Code Review"),
], []);

const result = await executor.ensureWorkflowMergeBoundaryTask(
liveTask,
{ reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" },
) as { blocked?: { code: string } };

expect(result.blocked).toBeUndefined();
expect(store.logEntry).not.toHaveBeenCalledWith("FN-B1", expect.stringContaining("Workflow merge boundary blocked:"), expect.anything(), expect.anything());
expect(store.moveTask).toHaveBeenCalledWith("FN-B1", "in-review", expect.anything());
});

it("reports the boundary proof as resolved/complete for an optional-group-only task", async () => {
const proof = await evaluateWorkflowMergeBoundary(
{
store: {
getTaskWorkflowSelection: () => ({ workflowId: "custom:foreach", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: foreachIr() }),
} as never,
loadMergeBoundaryInstances: async () => [],
},
{
id: "FN-B1",
column: "in-progress",
steps: [],
workflowStepResults: [optionalGroup("plan-review", "Plan Review"), optionalGroup("code-review", "Code Review")],
} as never,
"r1",
);

expect(proof.resolved).toBe(true);
expect(proof.hasRelevantNodeResult).toBe(true);
expect(proof.allResultsTerminal).toBe(true);
expect(proof.complete).toBe(true);
});

it("still blocks a non-terminal optional-group result (terminality stays mandatory)", async () => {
const { executor, liveTask } = boundaryHarness([
optionalGroup("plan-review", "Plan Review"),
optionalGroup("code-review", "Code Review", { status: "pending" }),
], []);

const result = await executor.ensureWorkflowMergeBoundaryTask(
liveTask,
{ reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" },
) as { blocked?: { code: string } };

expect(result.blocked).toMatchObject({ code: "non-terminal-node-result" });
});

it("still ignores a passed optional-group result from another phase (post-merge stays out)", async () => {
const { executor, store, liveTask } = boundaryHarness([
optionalGroup("post-merge-verification", "Post-merge verification", { phase: "post-merge" }),
], []);

const result = await executor.ensureWorkflowMergeBoundaryTask(
liveTask,
{ reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" },
) as { blocked?: { code: string } };

expect(result.blocked).toMatchObject({ code: "no-node-result" });
expect(store.moveTask).not.toHaveBeenCalled();
});

it("still requires terminal foreach instance coverage beside an optional-group result", async () => {
const { executor, liveTask } = boundaryHarness([
optionalGroup("plan-review", "Plan Review"),
], [{ id: "0", title: "Implement", status: "pending" }]);

const result = await executor.ensureWorkflowMergeBoundaryTask(
liveTask,
{ reason: "workflow-merge-boundary", nodeId: "merge", workflowId: "custom:foreach", runId: "r1" },
) as { blocked?: { code: string; missingInstanceCount: number } };

expect(result.blocked).toMatchObject({ code: "missing-foreach-instances", missingInstanceCount: 1 });
});

/*
FNXC:WorkflowMerge 2026-09-19-03:58:
`shouldCompleteChecklistAtWorkflowMerge` answers the same "did graph-native pre-merge work run?"
question as the boundary proof when no proof is supplied, so it shares the predicate. Assert both
directions: optional-group pre-merge work completes it; a post-merge-only result does not.
*/
const unfinishedSteps = [{ id: "0", title: "Implement", status: "pending" as const }];

it("completes the checklist fallback from optional-group pre-merge results", () => {
const { executor, liveTask } = boundaryHarness([
optionalGroup("plan-review", "Plan Review"),
optionalGroup("code-review", "Code Review"),
], unfinishedSteps);
const shouldComplete = (executor as unknown as {
shouldCompleteChecklistAtWorkflowMerge(task: unknown, proof?: { complete: boolean }): boolean;
}).shouldCompleteChecklistAtWorkflowMerge;
expect(shouldComplete(liveTask)).toBe(true);
});

it("does not complete the checklist fallback from a post-merge-only result", () => {
const { executor, liveTask } = boundaryHarness([
optionalGroup("post-merge-verification", "Post-merge verification", { phase: "post-merge" }),
], unfinishedSteps);
const shouldComplete = (executor as unknown as {
shouldCompleteChecklistAtWorkflowMerge(task: unknown, proof?: { complete: boolean }): boolean;
}).shouldCompleteChecklistAtWorkflowMerge;
expect(shouldComplete(liveTask)).toBe(false);
});
});
Loading
Loading