test(workspace): Phase D U2 — e2e merge + recovery harness (no-push invariant)
A real two-repo workspace lifecycle test in the engine-default lane (describeIfGit, not the merge gate). Test 1 drives landWorkspaceTask on two acquired sub-repos and asserts the NO-PUSH invariant directly: each sub-repo gets a real bare origin, and the test snapshots every origin + remote-tracking ref before/after and asserts byte-for-byte equality while the local refs/heads advance — a leaked push would move an origin ref and fail. Plus per-repo landedSha and finalize-exactly-once. Test 2 forces a repo-B conflict (repo A lands, task not done), then invokes the U1 reconcileWorkspacePartialLands reconciler under fake timers (enqueueMerge wired to the real in-process route) and asserts recovery completes with no double-land of repo A (its ref is unchanged from the first pass — proving the isRepoLanded skip). Engine-default lane confirmed: test:gate stays 649+58 (did not enter engine-core). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
350
packages/engine/src/__tests__/workspace-e2e.test.ts
Normal file
350
packages/engine/src/__tests__/workspace-e2e.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-11:30 (Phase D U2, KTD5 — end-to-end merge + recovery harness):
|
||||
LANE CHOICE — this is an ENGINE-DEFAULT, git-gated lane (the SAME `describeIfGit` guard as
|
||||
workspace-merger.test.ts), NOT a merge-gate (engine-core) test. The merge gate is an explicit
|
||||
allow-list that excludes real-git tests, so a real two-repo fixture e2e cannot run there; it runs
|
||||
in the non-blocking engine-default suite instead. We drive the REAL `landWorkspaceTask` against a
|
||||
REAL two-repo git fixture under a NON-git workspace root (createWorkspaceFixture) and invoke the
|
||||
U1 partial-land reconciler (`reconcileWorkspacePartialLands`) directly under FAKE TIMERS — no
|
||||
mock-the-world ProjectEngine shell, no real AI (the merge/review agents are injected deps and the
|
||||
squash is a plain `git merge --squash`), no unbounded temp walk, never touches port 4040 (FN-5048).
|
||||
|
||||
NO-PUSH INVARIANT (the whole D2/D5 premise — a HARD assertion):
|
||||
Each sub-repo gets a REAL bare `origin` remote that we push initial state to. We snapshot
|
||||
`git for-each-ref` over BOTH the bare origin AND the working repo's `refs/remotes/*` BEFORE and
|
||||
AFTER `landWorkspaceTask`. landWorkspaceTask lands each sub-repo onto its own LOCAL integration ref
|
||||
via CAS with NO remote push, so the origin's refs and every `refs/remotes/*` tracking ref must be
|
||||
BYTE-FOR-BYTE UNCHANGED while the LOCAL `refs/heads/main` advances. A leaked `git push` would move
|
||||
an origin ref and fail the snapshot equality — this is the strongest available proof of no-push.
|
||||
|
||||
Surfaces (FN-5893):
|
||||
- e2e happy + no-push: two acquired repos both land → BOTH local integration refs advance,
|
||||
per-repo `landedSha` is set, the task is finalized done EXACTLY once, AND origin/remote refs are
|
||||
unchanged (no push).
|
||||
- e2e partial-land recovery: force repo B to conflict → repo A lands (landedSha + ref advance), task
|
||||
NOT done; resolve B and run the U1 reconciler (re-enqueue → idempotent landWorkspaceTask) → B
|
||||
lands, task done, and repo A's ref did NOT advance a second time (isRepoLanded skip — no double-land).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
import { landWorkspaceTask } from "../merger-ai.js";
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
const TASK_ID = "FN-8001";
|
||||
const BRANCH = "fusion/fn-8001";
|
||||
|
||||
function configureIdentity(dir: string): void {
|
||||
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined recording store. Satisfies BOTH the `landWorkspaceTask` surface (getSettings/updateTask/
|
||||
* logEntry/appendAgentLog/getTask/moveTask/upsertTaskCommitAssociation/accumulateTokenUsage/emit)
|
||||
* AND the SelfHealingManager surface (listTasks/peekMergeQueue/recordRunAuditEvent/getRootDir),
|
||||
* over a single in-memory task map so a reconciler-routed land sees the SAME freshly-persisted
|
||||
* landedShas the first pass wrote.
|
||||
*/
|
||||
interface RecordingStore extends EventEmitter {
|
||||
tasks: Map<string, Task>;
|
||||
emitted: Array<{ event: string; payload: unknown }>;
|
||||
moveTaskCalls: Array<{ id: string; column: string }>;
|
||||
}
|
||||
|
||||
function createStore(rows: Task[], settings: Partial<Settings> = {}): TaskStore & RecordingStore {
|
||||
const emitter = new EventEmitter();
|
||||
const tasks = new Map<string, Task>(rows.map((t) => [t.id, t]));
|
||||
const emitted: Array<{ event: string; payload: unknown }> = [];
|
||||
const moveTaskCalls: Array<{ id: string; column: string }> = [];
|
||||
const realEmit = emitter.emit.bind(emitter);
|
||||
const store = Object.assign(emitter, {
|
||||
tasks,
|
||||
emitted,
|
||||
moveTaskCalls,
|
||||
getSettings: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000, ...settings } as unknown as Settings),
|
||||
listTasks: vi.fn(async (opts?: { column?: string }) => {
|
||||
const all = [...tasks.values()];
|
||||
return opts?.column ? all.filter((t) => t.column === opts.column) : all;
|
||||
}),
|
||||
getTask: vi.fn(async (id: string) => tasks.get(id) ?? null),
|
||||
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
|
||||
const cur = tasks.get(id);
|
||||
if (cur) tasks.set(id, { ...cur, ...patch } as Task);
|
||||
return tasks.get(id) as Task;
|
||||
}),
|
||||
moveTask: vi.fn(async (id: string, column: string) => {
|
||||
moveTaskCalls.push({ id, column });
|
||||
const cur = tasks.get(id);
|
||||
const next = { ...(cur ?? { id }), column } as Task;
|
||||
tasks.set(id, next);
|
||||
return next;
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined),
|
||||
accumulateTokenUsage: vi.fn().mockResolvedValue(undefined),
|
||||
peekMergeQueue: vi.fn().mockReturnValue([]),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
emit: (event: string, payload?: unknown) => {
|
||||
emitted.push({ event, payload });
|
||||
return realEmit(event, payload);
|
||||
},
|
||||
}) as unknown as TaskStore & RecordingStore;
|
||||
return store;
|
||||
}
|
||||
|
||||
function makeTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
title: "Workspace merge task",
|
||||
description: "",
|
||||
column: "in-review",
|
||||
branch: BRANCH,
|
||||
worktree: null,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
paused: false,
|
||||
workspaceWorktrees,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date(Date.now() - 30 * 60_000).toISOString(),
|
||||
...extra,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
/** A merge agent that performs the real squash in the clean room (no AI). */
|
||||
function squashMergeAgent(branch: string) {
|
||||
return async (cwd: string): Promise<void> => {
|
||||
configureIdentity(cwd);
|
||||
try {
|
||||
execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" });
|
||||
} catch {
|
||||
// squash reported conflicts — leave them for the test's expectation.
|
||||
}
|
||||
const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim();
|
||||
if (unmerged.length > 0) throw new Error("merge conflict: unresolved paths in clean room");
|
||||
const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim();
|
||||
if (staged.length === 0) return;
|
||||
execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" });
|
||||
};
|
||||
}
|
||||
|
||||
const approveReviewAgent = async (): Promise<string> => "REVIEW_VERDICT: approve";
|
||||
|
||||
/**
|
||||
* Give a sub-repo a REAL bare `origin` remote and push its initial state. Returns the bare repo
|
||||
* path so the test can snapshot its refs. Used to prove the NO-PUSH invariant: the origin must not
|
||||
* move across a land.
|
||||
*/
|
||||
function addOriginRemote(fx: WorkspaceFixture, repoRel: string): string {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const originDir = path.join(repoDir, "..", `${repoRel}-origin.git`);
|
||||
execSync(`git init --bare ${originDir}`, { cwd: repoDir, stdio: "pipe" });
|
||||
fx.git(repoRel, `git remote add origin ${originDir}`);
|
||||
fx.git(repoRel, "git push origin --all");
|
||||
return originDir;
|
||||
}
|
||||
|
||||
/** Snapshot ALL refs of a git dir (sha + name), normalized, for byte-for-byte comparison. */
|
||||
function snapshotRefs(gitDir: string): string {
|
||||
return execSync("git for-each-ref --format='%(objectname) %(refname)'", {
|
||||
cwd: gitDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/** Add a real `fusion/<id>` branch in a sub-repo with one non-conflicting own commit. */
|
||||
function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const wt = path.join(repoDir, ".wt-branch");
|
||||
fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`);
|
||||
configureIdentity(wt);
|
||||
writeFileSync(path.join(wt, "feature.txt"), content, "utf-8");
|
||||
execSync("git add feature.txt", { cwd: wt, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" });
|
||||
fx.git(repoRel, `git worktree remove --force ${wt}`);
|
||||
}
|
||||
|
||||
/** Make a sub-repo's integration tip and the task branch BOTH edit README so the squash conflicts. */
|
||||
function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const wt = path.join(repoDir, ".wt-conflict");
|
||||
fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`);
|
||||
configureIdentity(wt);
|
||||
writeFileSync(path.join(wt, "README.md"), "# branch-side change\n", "utf-8");
|
||||
execSync("git add README.md", { cwd: wt, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: wt, stdio: "pipe" });
|
||||
fx.git(repoRel, `git worktree remove --force ${wt}`);
|
||||
writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8");
|
||||
fx.git(repoRel, "git add README.md");
|
||||
fx.git(repoRel, 'git commit -m "main diverge README"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve repo B's conflict so a retry can land it: hard-align the task branch's README onto the
|
||||
* integration tip's content, then add B's non-conflicting feature on top of the (now conflict-free)
|
||||
* branch. After this the squash applies cleanly.
|
||||
*/
|
||||
function resolveConflictingRepo(fx: WorkspaceFixture, repoRel: string): void {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const wt = path.join(repoDir, ".wt-resolve");
|
||||
fx.git(repoRel, `git worktree add ${wt} ${BRANCH}`);
|
||||
configureIdentity(wt);
|
||||
// Take main's README content so the README no longer diverges, then add a unique file.
|
||||
const mainReadme = fx.git(repoRel, "git show refs/heads/main:README.md");
|
||||
writeFileSync(path.join(wt, "README.md"), `${mainReadme}\n`, "utf-8");
|
||||
writeFileSync(path.join(wt, "feature.txt"), "b feature\n", "utf-8");
|
||||
execSync("git add README.md feature.txt", { cwd: wt, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): resolve + feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" });
|
||||
fx.git(repoRel, `git worktree remove --force ${wt}`);
|
||||
}
|
||||
|
||||
describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase D U2)", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
beforeEach(() => activeSessionRegistry.clear());
|
||||
afterEach(() => {
|
||||
activeSessionRegistry.clear();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
fx?.cleanup();
|
||||
});
|
||||
|
||||
it("e2e happy: both repos land on LOCAL refs, landedSha per repo, finalize ONCE, NO push", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
const originA = addOriginRemote(fx, "repo-a");
|
||||
const originB = addOriginRemote(fx, "repo-b");
|
||||
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");
|
||||
addRepoBranchWithEdit(fx, "repo-b", "b feature\n");
|
||||
|
||||
const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main");
|
||||
const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main");
|
||||
|
||||
// NO-PUSH snapshot: bare origin refs + the working repo's refs/remotes tracking refs.
|
||||
const originABefore = snapshotRefs(originA);
|
||||
const originBBefore = snapshotRefs(originB);
|
||||
const remotesABefore = fx.git("repo-a", "git for-each-ref refs/remotes");
|
||||
const remotesBBefore = fx.git("repo-b", "git for-each-ref refs/remotes");
|
||||
|
||||
const store = createStore([
|
||||
makeTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
}),
|
||||
]);
|
||||
const task = store.tasks.get(TASK_ID)!;
|
||||
|
||||
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
|
||||
mergeAgent: squashMergeAgent(BRANCH),
|
||||
reviewAgent: approveReviewAgent,
|
||||
});
|
||||
|
||||
// Both landed.
|
||||
expect(result.allLanded).toBe(true);
|
||||
expect(result.finalized).toBe(true);
|
||||
for (const r of result.repos) expect(r.status).toBe("landed");
|
||||
|
||||
// Each repo's LOCAL integration ref advanced.
|
||||
expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore);
|
||||
expect(fx.git("repo-b", "git rev-parse refs/heads/main")).not.toBe(tipBBefore);
|
||||
|
||||
// Per-repo landedSha persisted on the task row.
|
||||
const persisted = store.tasks.get(TASK_ID)!.workspaceWorktrees!;
|
||||
expect(persisted["repo-a"].landedSha).toBeTruthy();
|
||||
expect(persisted["repo-b"].landedSha).toBeTruthy();
|
||||
expect(persisted["repo-a"].landedSha).toBe(fx.git("repo-a", "git rev-parse refs/heads/main"));
|
||||
expect(persisted["repo-b"].landedSha).toBe(fx.git("repo-b", "git rev-parse refs/heads/main"));
|
||||
|
||||
// Finalize EXACTLY once.
|
||||
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
|
||||
expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1);
|
||||
|
||||
// NO-PUSH invariant (HARD): origin refs and remote-tracking refs are BYTE-FOR-BYTE unchanged.
|
||||
expect(snapshotRefs(originA)).toBe(originABefore);
|
||||
expect(snapshotRefs(originB)).toBe(originBBefore);
|
||||
expect(fx.git("repo-a", "git for-each-ref refs/remotes")).toBe(remotesABefore);
|
||||
expect(fx.git("repo-b", "git for-each-ref refs/remotes")).toBe(remotesBBefore);
|
||||
});
|
||||
|
||||
it("e2e partial-land recovery: A lands, task not done → U1 reconciler lands B, no double-land of A", async () => {
|
||||
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
|
||||
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");
|
||||
makeConflictingRepo(fx, "repo-b");
|
||||
|
||||
const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main");
|
||||
|
||||
const store = createStore([
|
||||
makeTask({
|
||||
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
|
||||
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
|
||||
}),
|
||||
]);
|
||||
|
||||
// First pass: repo B conflicts → repo A lands, task NOT finalized.
|
||||
const first = await landWorkspaceTask(store, store.tasks.get(TASK_ID)!, fx.rootDir, {}, {
|
||||
mergeAgent: squashMergeAgent(BRANCH),
|
||||
reviewAgent: approveReviewAgent,
|
||||
});
|
||||
expect(first.allLanded).toBe(false);
|
||||
const byRepo = Object.fromEntries(first.repos.map((r) => [r.repo, r]));
|
||||
expect(byRepo["repo-a"].status).toBe("landed");
|
||||
expect(byRepo["repo-b"].status).toBe("failed");
|
||||
|
||||
const tipAAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main");
|
||||
expect(tipAAfterFirst).not.toBe(tipABefore); // A advanced once.
|
||||
expect(store.tasks.get(TASK_ID)!.workspaceWorktrees!["repo-a"].landedSha).toBe(tipAAfterFirst);
|
||||
expect(store.moveTaskCalls).toHaveLength(0); // task NOT done.
|
||||
expect(store.tasks.get(TASK_ID)!.column).toBe("in-review");
|
||||
|
||||
// Resolve repo B's conflict so a retry can land it.
|
||||
resolveConflictingRepo(fx, "repo-b");
|
||||
|
||||
// Wire enqueueMerge to the REAL in-process route: re-run landWorkspaceTask (idempotent — A is
|
||||
// skipped via isRepoLanded). Capture the routed promise so the test can await completion.
|
||||
const routedLands: Promise<unknown>[] = [];
|
||||
const enqueueMerge = (taskId: string): boolean => {
|
||||
routedLands.push(
|
||||
landWorkspaceTask(store, store.tasks.get(taskId)!, fx.rootDir, {}, {
|
||||
mergeAgent: squashMergeAgent(BRANCH),
|
||||
reviewAgent: approveReviewAgent,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: fx.rootDir,
|
||||
enqueueMerge,
|
||||
clearMergeActive: vi.fn(),
|
||||
} as never);
|
||||
|
||||
// FAKE TIMERS for the reconciler sweep timing (no real polling/waits).
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z"));
|
||||
const recovered = await manager.reconcileWorkspacePartialLands();
|
||||
expect(recovered).toBe(1);
|
||||
expect(routedLands).toHaveLength(1);
|
||||
|
||||
const recovery = (await routedLands[0]) as { allLanded: boolean; finalized: boolean };
|
||||
|
||||
// Recovery completes: B lands, task finalized done.
|
||||
expect(recovery.allLanded).toBe(true);
|
||||
expect(recovery.finalized).toBe(true);
|
||||
expect(store.tasks.get(TASK_ID)!.workspaceWorktrees!["repo-b"].landedSha).toBeTruthy();
|
||||
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
|
||||
expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1);
|
||||
|
||||
// NO DOUBLE-LAND: repo A's ref did NOT advance a second time (isRepoLanded skip).
|
||||
expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAAfterFirst);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user