diff --git a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-git-fixture.ts b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-git-fixture.ts index 12c89969d7..5395ec370a 100644 --- a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-git-fixture.ts +++ b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-git-fixture.ts @@ -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 { diff --git a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts index fdcd8be03d..8939c71576 100644 --- a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts +++ b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-harness.ts @@ -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 { - 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 { + 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 { 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 { - return git(this.fixture.repoDir, ["rev-parse", "main"]); + return git(this.fixture.integrationRepoDir, ["rev-parse", "main"]); } async observe(taskId: string): Promise { @@ -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 { 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 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 aa79bfdf7b..21421589c7 100644 --- a/packages/engine/src/__tests__/pipeline-smoke/_pipeline-mock-scripts.ts +++ b/packages/engine/src/__tests__/pipeline-smoke/_pipeline-mock-scripts.ts @@ -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; }