test(FN-WF): prove the pipeline end to end on multi-repository workspaces
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.
This commit is contained in:
@@ -177,6 +177,8 @@ export class PipelineSmokeHarness {
|
||||
private manualHoldTaskIds = new Set<string>();
|
||||
private readonly promptRevisions = new Map<string, number>();
|
||||
private readonly mockScriptStates = new Map<string, { behavior: PipelineScriptedMergeBehavior; state: PipelineMockScriptState }>();
|
||||
/** Newest non-empty branch per task; a workspace row only gains one at acquisition. */
|
||||
private readonly scriptedBranches = new Map<string, string>();
|
||||
|
||||
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, { branch?: string }> }): 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<boolean> {
|
||||
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<void>((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<void>((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<PipelineMergeOutcome> {
|
||||
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<Task> {
|
||||
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") {
|
||||
|
||||
@@ -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<string>;
|
||||
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;
|
||||
/*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user