test(FN-WF): add multi-repository workspace support to the pipeline smoke harness
The smoke lane was single-repository only, so the workspace path — the one that
actually broke in production — was never driven end to end. Adds:
- `createPipelineWorkspaceFixture`: a real workspace project whose ROOT is a plain
container (no Git metadata) holding per-repository checkouts with their own
origins and a `.fusion/workspace.json`. The single-repo fixture cannot express
this shape, because there the root and the repository are the same directory —
which is why a node resolving the root as a worktree still worked by accident.
- `PipelineGitFixture.integrationRepoDir`: integration git (`rev-parse main`,
ancestry, status, worktree prune) now targets a repository rather than the
project root. Single-repo fixtures answer `repoDir`, so nothing changes there.
- `PipelineSmokeHarness.create(pg, { workspace: true })` and an optional confirmed
`repositoryScope` on `createPipelineTask`, which workspace acquisition requires
before any write-capable node runs.
- The executor mock now resolves the repository it can commit in. Its
`existsSync(cwd/.git)` guard skipped the whole implementation block on a
workspace session (cwd is the task directory), so no commit existed and Code
Review reported "No changes — not reviewed" on an untouched scoped repository.
Measured with these in place, a workspace task on builtin:coding-ideas-v2 now
clears plan, plan-review, parse, verification, documentation-delivery and code
review ("All 1 modified in-scope sub-repo(s) approved"). That is the direct
end-to-end confirmation that the FN-158-shaped session-boundary fix works: the
write-capable documentation gate runs in a workspace instead of dying with
"Refusing to start coding agent in incomplete worktree".
It then fails at the workspace LAND step with "Workspace repository repo1 could
not land". The underlying cause is written to the task log rather than stdout and
is not yet identified, so the end-to-end workspace drive test is deliberately NOT
committed: shipping it red would put a permanently failing test in the lane, and
weakening it to assert only the progress reached would be appeasement. Mono-repo
coverage is unchanged and green (63 tests, 19/19 scenarios).
This commit is contained in:
@@ -14,6 +14,17 @@ export interface PipelineGitFixture {
|
||||
readonly rootDir: string;
|
||||
readonly repoDir: string;
|
||||
readonly bareOriginDir: string;
|
||||
/*
|
||||
FNXC:PipelineSmoke 2026-08-24-11:05:
|
||||
In a WORKSPACE project `repoDir` is the workspace root: a plain directory holding per-repository
|
||||
checkouts, with no Git metadata of its own. Integration git (`rev-parse main`, ancestry, status,
|
||||
worktree prune) must therefore target a repository, never the root — resolving the root as a repo
|
||||
is exactly the mistake that produced "Refusing to start coding agent in incomplete worktree" in
|
||||
production. Single-repo fixtures answer `repoDir` here, so existing call sites are unchanged.
|
||||
*/
|
||||
readonly integrationRepoDir: string;
|
||||
/** Workspace-relative repository paths; empty for a single-repository fixture. */
|
||||
readonly repos: readonly string[];
|
||||
git(args: string[]): string;
|
||||
seedFile(name: string, content: string): void;
|
||||
createEmptyBranch(branch: string): void;
|
||||
@@ -48,6 +59,8 @@ export function createPipelineGitFixture(): PipelineGitFixture {
|
||||
rootDir,
|
||||
repoDir,
|
||||
bareOriginDir,
|
||||
integrationRepoDir: repoDir,
|
||||
repos: [],
|
||||
git: (args) => git(repoDir, args),
|
||||
seedFile: (name, content) => {
|
||||
const target = path.join(repoDir, name);
|
||||
@@ -61,6 +74,62 @@ export function createPipelineGitFixture(): PipelineGitFixture {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PipelineSmoke 2026-08-24-11:05:
|
||||
A real multi-repository workspace: the root is NOT a git repository, each declared repo is, and
|
||||
`.fusion/workspace.json` is what makes `loadWorkspaceConfig` resolve it as a workspace project. This
|
||||
is the shape whose write-capable review gates failed in production; the single-repo fixture cannot
|
||||
reproduce it because its root and its repository are the same directory.
|
||||
*/
|
||||
export function createPipelineWorkspaceFixture(repos: readonly string[] = ["repo1", "repo2"]): PipelineGitFixture {
|
||||
const rootDir = mkdtempSync(path.join(os.tmpdir(), PIPELINE_FIXTURE_PREFIX));
|
||||
const workspaceRoot = path.join(rootDir, "workspace");
|
||||
const bareOriginDir = path.join(rootDir, `${repos[0]}-origin.git`);
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
|
||||
for (const rel of repos) {
|
||||
const repoDir = path.join(workspaceRoot, rel);
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
git(repoDir, ["init", "-b", "main"]);
|
||||
git(repoDir, ["config", "user.email", "pipeline-smoke@example.test"]);
|
||||
git(repoDir, ["config", "user.name", "Pipeline Smoke"]);
|
||||
writeFileSync(path.join(repoDir, "README.md"), `# ${rel}\n`, "utf8");
|
||||
writeFileSync(path.join(repoDir, ".gitignore"), ".fusion/\n.worktrees/\n", "utf8");
|
||||
git(repoDir, ["add", "."]);
|
||||
git(repoDir, ["commit", "-m", "baseline"]);
|
||||
const origin = path.join(rootDir, `${rel}-origin.git`);
|
||||
git(rootDir, ["init", "--bare", origin]);
|
||||
git(repoDir, ["remote", "add", "origin", origin]);
|
||||
git(repoDir, ["push", "-u", "origin", "main"]);
|
||||
}
|
||||
|
||||
mkdirSync(path.join(workspaceRoot, ".fusion", "tasks"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(workspaceRoot, ".fusion", "workspace.json"),
|
||||
`${JSON.stringify({ repos: [...repos] }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const primary = path.join(workspaceRoot, repos[0]);
|
||||
return {
|
||||
rootDir,
|
||||
repoDir: workspaceRoot,
|
||||
bareOriginDir,
|
||||
integrationRepoDir: primary,
|
||||
repos: [...repos],
|
||||
git: (args) => git(primary, args),
|
||||
seedFile: (name, content) => {
|
||||
const target = path.join(primary, name);
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, content, "utf8");
|
||||
},
|
||||
createEmptyBranch: (branch) => {
|
||||
git(primary, ["branch", "-f", branch, "main"]);
|
||||
},
|
||||
cleanup: () => rmSync(rootDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
|
||||
export function fixturePathsExist(fixture: PipelineGitFixture): boolean {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { runHoldReleaseSweep } from "../../execution/hold-release.js";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
import { reconcileRecovery } from "../../recovery-reconciler.js";
|
||||
import { createPipelineClock, type PipelineClock } from "./_pipeline-clock.js";
|
||||
import { createPipelineGitFixture, type PipelineGitFixture } from "./_pipeline-git-fixture.js";
|
||||
import { createPipelineGitFixture, createPipelineWorkspaceFixture, type PipelineGitFixture } from "./_pipeline-git-fixture.js";
|
||||
import { createPipelineNoAiGuard, type PipelineNoAiGuard } from "./_pipeline-no-ai-guard.js";
|
||||
import {
|
||||
installPipelineMockScripts,
|
||||
@@ -225,8 +225,16 @@ export class PipelineSmokeHarness {
|
||||
}
|
||||
}
|
||||
|
||||
static async create(pg: SharedPgTaskStoreHarness, options: { autoMerge?: boolean } = {}): Promise<PipelineSmokeHarness> {
|
||||
const fixture = createPipelineGitFixture();
|
||||
/*
|
||||
FNXC:PipelineSmoke 2026-08-24-11:05:
|
||||
`workspace: true` swaps in a real multi-repository project. Everything downstream is unchanged
|
||||
because the fixture, not the harness, decides which directory integration git runs in.
|
||||
*/
|
||||
static async create(
|
||||
pg: SharedPgTaskStoreHarness,
|
||||
options: { autoMerge?: boolean; workspace?: boolean } = {},
|
||||
): Promise<PipelineSmokeHarness> {
|
||||
const fixture = options.workspace ? createPipelineWorkspaceFixture() : createPipelineGitFixture();
|
||||
const releaseFixtureEnvironment = await acquireFixtureGlobalHome(fixture);
|
||||
try {
|
||||
const taskStore = new TaskStore(fixture.repoDir, undefined, { asyncLayer: pg.layer() });
|
||||
@@ -397,6 +405,14 @@ export class PipelineSmokeHarness {
|
||||
readonly codeReview?: boolean;
|
||||
readonly initialColumn?: "creation" | "hold";
|
||||
readonly noCommitsExpected?: boolean;
|
||||
/*
|
||||
FNXC:PipelineSmoke 2026-08-24-11:05:
|
||||
A workspace task must carry a CONFIRMED repository scope before any write-capable node runs:
|
||||
acquisition refuses without it, and the session boundary is derived from exactly these
|
||||
repositories. Planning normally confirms it; the fixture states it directly so the scenario
|
||||
measures execution rather than re-testing scope confirmation.
|
||||
*/
|
||||
readonly repositoryScope?: readonly string[];
|
||||
} = {},
|
||||
): Promise<PipelineTaskSeed> {
|
||||
const ir = getBuiltinWorkflow(workflowId)?.ir
|
||||
@@ -421,6 +437,15 @@ export class PipelineSmokeHarness {
|
||||
if (selected?.workflowId !== workflowId) {
|
||||
throw new Error(`Pipeline smoke task ${taskId} did not retain selected workflow ${workflowId}.`);
|
||||
}
|
||||
if (options.repositoryScope?.length) {
|
||||
await this.store.updateTask(taskId, {
|
||||
repositoryScope: {
|
||||
state: "confirmed",
|
||||
repositories: [...options.repositoryScope],
|
||||
revision: 1,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PipelineSmoke 2026-08-23-20:23:
|
||||
@@ -697,7 +722,7 @@ export class PipelineSmokeHarness {
|
||||
}
|
||||
|
||||
async integrationSha(): Promise<string> {
|
||||
return git(this.fixture.repoDir, ["rev-parse", "main"]);
|
||||
return git(this.fixture.integrationRepoDir, ["rev-parse", "main"]);
|
||||
}
|
||||
|
||||
async observe(taskId: string): Promise<PipelineObservedState> {
|
||||
@@ -717,13 +742,13 @@ export class PipelineSmokeHarness {
|
||||
]);
|
||||
const effectiveAutoMergeOff = task.autoMerge === false || (settings.autoMerge === false && task.autoMerge !== true);
|
||||
const branchReachableFromIntegration = task.mergeDetails?.commitSha
|
||||
? (() => { try { git(this.fixture.repoDir, ["merge-base", "--is-ancestor", task.mergeDetails!.commitSha!, "main"]); return true; } catch { return false; } })()
|
||||
? (() => { try { git(this.fixture.integrationRepoDir, ["merge-base", "--is-ancestor", task.mergeDetails!.commitSha!, "main"]); return true; } catch { return false; } })()
|
||||
: false;
|
||||
const emptyTaskDiff = (() => {
|
||||
if (task.noCommitsExpected !== true || !task.branch) return false;
|
||||
const base = task.baseCommitSha ?? "main";
|
||||
try {
|
||||
git(this.fixture.repoDir, ["diff", "--quiet", `${base}...${task.branch}`]);
|
||||
git(this.fixture.integrationRepoDir, ["diff", "--quiet", `${base}...${task.branch}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -1082,9 +1107,9 @@ export class PipelineSmokeHarness {
|
||||
): Promise<Task> {
|
||||
const before = await this.freshTask(taskId);
|
||||
if (before.worktree) rmSync(before.worktree, { recursive: true, force: true });
|
||||
git(this.fixture.repoDir, ["worktree", "prune"]);
|
||||
git(this.fixture.integrationRepoDir, ["worktree", "prune"]);
|
||||
if (before.branch) {
|
||||
try { git(this.fixture.repoDir, ["branch", "-D", before.branch]); } catch { /* stale branch is already absent */ }
|
||||
try { git(this.fixture.integrationRepoDir, ["branch", "-D", before.branch]); } catch { /* stale branch is already absent */ }
|
||||
}
|
||||
await this.store.updateTask(taskId, {
|
||||
worktree: null,
|
||||
@@ -1102,7 +1127,7 @@ export class PipelineSmokeHarness {
|
||||
prevents an apparently green S11 from skipping acquisition and manufacturing only downstream
|
||||
review evidence after the checkout disappeared.
|
||||
*/
|
||||
const rootStatus = git(this.fixture.repoDir, ["status", "--porcelain"]);
|
||||
const rootStatus = git(this.fixture.integrationRepoDir, ["status", "--porcelain"]);
|
||||
if (rootStatus) throw new Error(`S11: recovery left integration checkout dirty: ${rootStatus}`);
|
||||
// Planning evidence is still current because this scenario removes the execution checkout,
|
||||
// not PROMPT.md. Preserve its real Plan Review approval and let the resumed graph create fresh
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { MockScript } from "../../providers/mock-provider.js";
|
||||
import { setMockScript } from "../../providers/mock-provider.js";
|
||||
@@ -180,7 +180,22 @@ export function installPipelineMockScripts(input: {
|
||||
acquisition, graph projection, review fingerprints, and the merger's local Git input on
|
||||
the same path as an operator task instead of pre-seeding a branch before execution starts.
|
||||
*/
|
||||
if (!state.implementationCommitted && behavior.commitImplementation !== false && existsSync(join(context.options.cwd, ".git"))) {
|
||||
/*
|
||||
FNXC:PipelineSmoke 2026-08-24-11:05:
|
||||
A WORKSPACE session runs from the task DIRECTORY, whose per-repository children hold the Git
|
||||
metadata; the container itself has no `.git`. The original `existsSync(cwd/.git)` guard
|
||||
therefore skipped the whole block on a workspace task, the executor produced no commit, and
|
||||
Code Review reported "No changes — not reviewed" on a scoped repository that was genuinely
|
||||
untouched. Resolve the repository the session can actually commit in, exactly as a real
|
||||
executor does through fn_acquire_repo_worktree.
|
||||
*/
|
||||
const implementationRepoDir = existsSync(join(context.options.cwd, ".git"))
|
||||
? context.options.cwd
|
||||
: readdirSync(context.options.cwd, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => join(context.options.cwd, entry.name))
|
||||
.find((candidate) => existsSync(join(candidate, ".git")));
|
||||
if (!state.implementationCommitted && behavior.commitImplementation !== false && implementationRepoDir) {
|
||||
/*
|
||||
FNXC:PipelineSmoke 2026-08-23-21:45:
|
||||
Most rows use an isolated output file, while S13 deliberately writes README.md so the
|
||||
@@ -191,10 +206,10 @@ export function installPipelineMockScripts(input: {
|
||||
path: "pipeline-smoke-output.txt",
|
||||
content: `pipeline smoke implementation for ${input.taskId}\n`,
|
||||
};
|
||||
writeFileSync(join(context.options.cwd, implementation.path), implementation.content, "utf8");
|
||||
git(context.options.cwd, ["add", "--", implementation.path]);
|
||||
if (git(context.options.cwd, ["diff", "--cached", "--name-only"])) {
|
||||
git(context.options.cwd, ["commit", "-m", `feat(${input.taskId}): mock executor implementation`]);
|
||||
writeFileSync(join(implementationRepoDir, implementation.path), implementation.content, "utf8");
|
||||
git(implementationRepoDir, ["add", "--", implementation.path]);
|
||||
if (git(implementationRepoDir, ["diff", "--cached", "--name-only"])) {
|
||||
git(implementationRepoDir, ["commit", "-m", `feat(${input.taskId}): mock executor implementation`]);
|
||||
}
|
||||
state.implementationCommitted = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user