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); + }); +});