From f4487b4b311d18687d6131cfce0b5787f4e0c945 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Mon, 24 Aug 2026 15:29:07 +0000 Subject: [PATCH] test(FN-WF): prove the pipeline end to end on multi-repository workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke lane now drives a workspace task on builtin:coding-ideas-v2 from the Ideas intake to `merged-done`, alongside the existing single-repository coverage. 6 files, 65 tests, 19/19 scenarios, 77.1s of the 90s budget. Two fixture defects stood between the harness and that proof, both of the same shape: a workspace task legitimately has NO task-level `branch` — each repository owns one under `workspaceWorktrees[repo].branch`. - The scripted merger was handed `task.branch ?? ""`, so it ran `git merge --squash` on an empty ref ("merge: - not something we can merge"), surfacing only as the generic "Workspace repository repo1 could not land". - Resolving the branch at INSTALL time was still wrong: the merge is attempted in the same turn as the first install, before acquisition has created any per-repository worktree. The scripts now receive an async getter that reads the live task when the merger actually runs, so ordering cannot make it stale. This also corrects an earlier misattribution recorded in the previous commit: the land failure was NOT a missing `repositoryScope`. A probe showed the scope confirmed, the review evidence recorded, and `workspaceWorktrees.repo1.branch` populated — the harness simply never passed that branch to the merger. The workspace path is now measured, not inferred: plan, plan-review, parse, verification, documentation-delivery, completion-summary, code review ("All 1 modified in-scope sub-repo(s) approved") and the per-repository land all run, with `mergeDetails.mergeConfirmed` asserted on the persisted row. --- .../pipeline-smoke/_pipeline-harness.ts | 42 ++++++++++-- .../pipeline-smoke/_pipeline-mock-scripts.ts | 13 +++- .../pipeline-workspace.pipeline.test.ts | 66 +++++++++++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 packages/engine/src/__tests__/pipeline-smoke/pipeline-workspace.pipeline.test.ts diff --git a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts index 8939c71576..2f27620630 100644 --- a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts +++ b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts @@ -177,6 +177,8 @@ export class PipelineSmokeHarness { private manualHoldTaskIds = new Set(); private readonly promptRevisions = new Map(); private readonly mockScriptStates = new Map(); + /** Newest non-empty branch per task; a workspace row only gains one at acquisition. */ + private readonly scriptedBranches = new Map(); private constructor( readonly pg: SharedPgTaskStoreHarness, @@ -354,6 +356,7 @@ export class PipelineSmokeHarness { try { activeSessionRegistry.clear(); this.mockScriptStates.clear(); + this.scriptedBranches.clear(); resetMockScripts(); await this.engine.stop(); this.guard.restore(); @@ -593,7 +596,31 @@ export class PipelineSmokeHarness { } } + /* + FNXC:PipelineSmoke 2026-08-24-15:40: + The scripted merger squashes the branch it is handed. A WORKSPACE task legitimately has no + task-level `branch`: each repository owns its own under `workspaceWorktrees[repo].branch`. + `task.branch ?? ""` therefore handed the mock an EMPTY ref, and the land failed with + `git merge --squash` on nothing ("merge: - not something we can merge"), surfacing as the + generic "Workspace repository repo1 could not land". Resolve the per-repository branch for a + workspace row; single-repository rows keep `task.branch` unchanged. + */ + private static scriptedMergeBranch(task: { branch?: string; workspaceWorktrees?: Record }): string { + if (task.branch) return task.branch; + const workspaceBranch = Object.values(task.workspaceWorktrees ?? {}) + .map((entry) => entry?.branch) + .find((branch): branch is string => typeof branch === "string" && branch.length > 0); + return workspaceBranch ?? ""; + } + private installScriptedAgents(taskId: string, branch: string, behavior: PipelineScriptedMergeBehavior): void { + /* + FNXC:PipelineSmoke 2026-08-24-15:40: + Remember the newest non-empty branch and hand the scripts a GETTER. Installation happens before + a workspace task owns any branch, so a captured value stayed empty for the whole run even though + later installs resolved it correctly. + */ + if (branch) this.scriptedBranches.set(taskId, branch); let entry = this.mockScriptStates.get(taskId); if (!entry || entry.behavior !== behavior) { entry = { @@ -604,7 +631,12 @@ export class PipelineSmokeHarness { } installPipelineMockScripts({ taskId, - branch, + branch: async () => { + const live = await this.freshTask(taskId).catch(() => undefined); + const resolved = live ? PipelineSmokeHarness.scriptedMergeBranch(live) : ""; + if (resolved) this.scriptedBranches.set(taskId, resolved); + return resolved || this.scriptedBranches.get(taskId) || ""; + }, behavior, state: entry.state, observeMockRuntime: () => this.guard.assertMockRuntime("mock/scripted"), @@ -613,7 +645,7 @@ export class PipelineSmokeHarness { async tryEnqueueAutomaticMerge(taskId: string, behavior: PipelineScriptedMergeBehavior = {}): Promise { const task = await this.freshTask(taskId); - this.installScriptedAgents(taskId, task.branch ?? "", behavior); + this.installScriptedAgents(taskId, PipelineSmokeHarness.scriptedMergeBranch(task), behavior); const accepted = this.engine.enqueueMerge(taskId); await new Promise((resolve) => setImmediate(resolve)); return accepted; @@ -651,7 +683,7 @@ export class PipelineSmokeHarness { : undefined; const completeColumn = ir ? resolveCompleteColumn(ir) : undefined; if (!completeColumn) throw new Error(`Pipeline automatic merge cannot resolve complete lane for ${taskId}.`); - this.installScriptedAgents(taskId, task.branch ?? "", behavior); + this.installScriptedAgents(taskId, PipelineSmokeHarness.scriptedMergeBranch(task), behavior); await new Promise((resolve, reject) => { const onMoved = ({ task: moved, to }: { task: Task; to: string }) => { if (moved.id !== taskId || to !== completeColumn) return; @@ -708,7 +740,7 @@ export class PipelineSmokeHarness { async admitAndMerge(taskId: string, options: { signal?: AbortSignal; behavior?: PipelineScriptedMergeBehavior; manual?: boolean } = {}): Promise { const task = await this.freshTask(taskId); - this.installScriptedAgents(taskId, task.branch ?? "", options.behavior ?? {}); + this.installScriptedAgents(taskId, PipelineSmokeHarness.scriptedMergeBranch(task), options.behavior ?? {}); try { const result = options.manual ? await this.engine.onMerge(taskId, { signal: options.signal }) @@ -1152,7 +1184,7 @@ export class PipelineSmokeHarness { behavior: PipelineScriptedMergeBehavior = {}, ): Promise { const task = await this.freshTask(taskId); - this.installScriptedAgents(taskId, task.branch ?? "", behavior); + this.installScriptedAgents(taskId, PipelineSmokeHarness.scriptedMergeBranch(task), behavior); const executor = this.wireExecutor(); const seams = executor.createAuthoritativeWorkflowSeams(await this.store.getSettings()); if (typeof seams.planning !== "function" || typeof seams.execute !== "function" || typeof seams.merge !== "function") { diff --git a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-mock-scripts.ts b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-mock-scripts.ts index 21421589c7..9649b04d8f 100644 --- a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-mock-scripts.ts +++ b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-mock-scripts.ts @@ -84,7 +84,14 @@ function reviewText(mode: PipelineReviewMode, reviewKind: "plan" | "code", prior */ export function installPipelineMockScripts(input: { readonly taskId: string; - readonly branch: string; + /* + FNXC:PipelineSmoke 2026-08-24-15:40: + Resolved when the merger RUNS, not when the script is installed. A workspace task has no branch at + all until acquisition creates its per-repository worktree, and the scripts are installed before + that: capturing the value handed the merger an empty ref and the land failed with + `git merge --squash` on nothing. A getter cannot go stale. + */ + readonly branch: () => Promise; readonly behavior?: PipelineScriptedMergeBehavior; readonly state?: PipelineMockScriptState; readonly observeMockRuntime: () => void; @@ -102,7 +109,9 @@ export function installPipelineMockScripts(input: { behavior.onMergeEntered?.(); await behavior.waitForMerge; try { - git(context.options.cwd, ["merge", "--squash", input.branch]); + const mergeBranch = await input.branch(); + if (!mergeBranch) throw new Error("pipeline smoke merger has no branch to squash"); + git(context.options.cwd, ["merge", "--squash", mergeBranch]); } catch (error) { if (!behavior.resolveConflicts) throw error; /* diff --git a/packages/engine/src/__tests__/pipeline-smoke/pipeline-workspace.pipeline.test.ts b/packages/engine/src/__tests__/pipeline-smoke/pipeline-workspace.pipeline.test.ts new file mode 100644 index 0000000000..ef2e85e870 --- /dev/null +++ b/packages/engine/src/__tests__/pipeline-smoke/pipeline-workspace.pipeline.test.ts @@ -0,0 +1,66 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../../../core/src/__test-utils__/pg-test-harness.js"; +import { hasGit } from "./_pipeline-git-fixture.js"; +import { PipelineSmokeHarness } from "./_pipeline-harness.js"; + +const describeIfReady = hasGit ? pgDescribe : describe.skip; + +/* +FNXC:PipelineSmoke 2026-08-24-15:40: +Multi-repository coverage. The single-repo lane structurally cannot reach this shape: there the +project root and the repository are the same directory, so a node resolving the root as a worktree +still works by accident. In a workspace the root is a plain container of per-repository checkouts +with no Git metadata, and that difference is what broke production — a write-capable review gate +declared no session boundary, the single-repo assertion resolved the container as a worktree, and +the gate died with "Refusing to start coding agent in incomplete worktree" before producing a +verdict. Both the topology tests and the mono-repo lane stayed green throughout. +*/ +describeIfReady("pipeline smoke: multi-repository workspace", () => { + const pg: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_pipeline_smoke_workspace", + projectId: "pipeline-smoke-workspace", + }); + let harness: PipelineSmokeHarness; + + beforeAll(pg.beforeAll); + beforeEach(async () => { + await pg.beforeEach(); + harness = await PipelineSmokeHarness.create(pg, { workspace: true }); + }); + afterEach(async () => { + await harness.dispose(); + await pg.afterEach(); + }); + afterAll(pg.afterAll); + + it("builds a workspace project whose root is not a repository", async () => { + expect(harness.fixture.repos).toEqual(["repo1", "repo2"]); + expect(harness.fixture.integrationRepoDir).not.toBe(harness.fixture.repoDir); + expect(harness.fixture.integrationRepoDir.startsWith(harness.fixture.repoDir)).toBe(true); + expect(await harness.integrationSha()).toMatch(/^[0-9a-f]{7,40}$/); + }); + + it("drives a workspace task through the review-column gates to a merge", async () => { + const task = await harness.createPipelineTask("builtin:coding-ideas-v2", { + idPrefix: "WS", + initialColumn: "hold", + repositoryScope: ["repo1"], + }); + const result = await harness.driveToDeclaredTerminal(task.id, "merged-done"); + expect(result.observedTerminal).toBe("merged-done"); + expect(result.wedge).toBeUndefined(); + + /* + The per-repository branch is the merge input for a workspace row; a task-level `branch` is + legitimately absent. Assert the scoped repository actually recorded one, because handing the + merger an empty ref is precisely how this path failed. + */ + const live = await harness.store.getTask(task.id); + expect(live.workspaceWorktrees?.repo1?.branch).toMatch(/^fusion\//); + expect(live.mergeDetails?.mergeConfirmed).toBe(true); + }); +});