FN-112: capture workspace merge evidence from task worktrees

Capture workspace merge evidence from immutable acquisition baselines to persisted task branches so real linked task worktrees land reviewed changes reliably.

- Compare review fingerprints and landing ranges against the persisted task branch rather than the live worktree HEAD.
- Add linked-task-worktree regression coverage for per-repository landing proof and exactly-once finalization.
- Document the regression and publish the workspace merge fix in a patch changeset.

Files changed:
 .../fn-112-workspace-task-worktree-evidence.md     |  7 ++++
 docs/architecture.md                               |  1 +
 ...e-empty-merge-boundary-finalization-livelock.md |  8 +++-
 .../engine/src/__tests__/_workspace-fixture.ts     | 15 ++++++++
 .../engine/src/__tests__/workspace-e2e.test.ts     | 31 ++++++++++++---
 .../__tests__/workspace-merger-scope-gates.test.ts |  2 +-
 .../engine/src/__tests__/workspace-merger.test.ts  | 45 +++++++++++++++++++++-
 packages/engine/src/merge/merger-ai.ts             | 29 ++++++++++++--
 8 files changed, 125 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-112

Fusion-Task-Lineage: 66e8498e-13f2-461b-ae38-bf750383115b

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 17:54:20 +00:00
parent 294e826ea0
commit 2430ce69b0
8 changed files with 125 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix workspace auto-merge for work in linked task worktrees.
category: fix
dev: Capture merge evidence from each acquisition baseline to its persisted task branch.

View File

@@ -629,6 +629,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
- **Workspace acquisition shape:** per-repo acquisition persists only `workspaceWorktrees`; it never exposes a sub-repo path or branch through singular `task.worktree`/`task.branch`, including if the final workspace-state write fails. This preserves workspace classification for dashboard rendering, self-healing, and executor dispatch.
- **No root task worktree (FN-034):** when `.fusion/workspace.json` declares repositories, planning, execution, graph nodes, code/plan review, pause/resume, restart, and worktree-session recovery acquire or reuse only the declared repositories' worktrees. The workspace root and `<workspace>/.worktrees/<task>` are never task checkout or reviewer cwd. Historical singular `worktree`, `branch`, execution-base, and root-session routing metadata is cleared through an advisory-locked, project-scoped mutation that preserves every `workspaceWorktrees` entry and completed-step progress; recovery never deletes or recreates a root checkout.
- **Workspace entry mutation (FN-9052):** every per-repository `workspaceWorktrees` update goes through `TaskStore.mergeWorkspaceWorktreeEntry`, which holds the per-task PostgreSQL advisory transaction lock and merges one key under the composite project/task scope. Per-repo callers must never wholesale-replace the map, because a concurrent sibling acquisition, landing, failure, or teardown mutation would otherwise lose its entry.
- **Workspace merge-boundary evidence (FN-112):** landing compares the immutable per-repository acquisition baseline (`baseCommitSha`) to the persisted task branch for changed files, binary review fingerprints, and ahead-but-net-zero evidence. A live linked task worktree normally has `HEAD === entry.branch`, so comparing its `HEAD` to `entry.branch` is a self-comparison that falsely reports no work. Legacy entries without a captured SHA resolve their recorded base target and derive its merge base against the task branch. Code Review and landing therefore approve and consume the same range; unreadable bases, missing scope evidence, and unexplained empty ranges still fail closed before leases, intents, ref advancement, or pushes. Main-checkout fixture stand-ins can hide this defect because their `HEAD` is the integration branch, so real-Git lifecycle coverage keeps a registered linked task worktree alive.
- **Durable multi-node workspace coordination (FN-9059):** `project.workspace_coordination_leases`, serialized under a project/resource advisory transaction lock, owns sub-repository acquire exclusivity, per-repository land, workspace liveness/recovery guards, and merge-dispatch admission across engine processes. The owner triple `(taskId, nodeId, incarnationId)` makes only same-process re-entry idempotent; the same task from another node or process incarnation contends. Fresh/reclaimed claims receive a monotonically increasing fence token, while re-entry and renewal retain it. Git-writing tenancies publish once per tenancy—on acquire/reclaimed-expired, not re-entry—the per-repository `refs/fusion/workspace-lease/<repo-slug>` or per-merge `refs/fusion/merge-dispatch/<task-id>` fence ref. A workspace merge dispatches its deterministic per-merge pin to every target sub-repository remote before any workspace land begins; the workspace root need not be a git checkout and is never used as a substitute remote. A re-entrant claimant reuses its pin, except to repair a claim-to-publish gap with no pin; acquire-kind leases have no fence ref. A land push atomically CASes the target ref observed by the tenancy plus its repository fence and enclosing merge-dispatch fence refs in one push; a merge-only push CASes its target and dispatch pin. Thus a superseded owner is rejected even when the target tip has not moved. Lease-protected durable writes run through `withValidWorkspaceLeaseAsync`, and lease mutation is owner-and-fence conditional; no validate-then-act path is valid.
- **Workspace merge and land crash boundaries (FN-9059):** merge dispatch claims at body dispatch rather than enqueue; a losing claimant benignly drops. Each live per-repository land lease renews before its five-minute TTL throughout dependency sync, AI merge/review, retry, push, and intent resolution. A refused or failed renewal aborts the land body before its next durable or remote commit point; renewal only maintains liveness, while the current owner/fence handle remains the write authority. The body re-proves its fence at admission, the target-plus-fence atomic push, subsequent PR/branch/status effects, and terminal outcome persistence. Non-CASable effects are idempotent and follow the fenced push, so mid-merge expiry stops a superseded body at its next commit point; a pushed result whose outcome cannot persist is `merge-completed-unrecorded`, never a re-push. Before workspace land pushes, `project.workspace_land_intents` records the expected tip, intended SHA, remote/integration identity, and fence pin. The ordered protocol is intent → atomic push → lease-validated `landedSha` persist plus intent resolution. The node-independent reconciler lists pending intents project-wide, fetches the recorded remote, and resolves reachability on its integration ref. Only a live holder (its own/equal fence or a lower-fence predecessor) or recovery with no held unexpired lease can resolve an intent; stale fence matches, local tip equality, and local object availability are not authority.
- **Workspace File Scope interpretation (FN-078):** squash gates, completion scope-leak checks, and main-checkout guards use one repo-local resolver. A matching repo-prefixed declaration has priority; only a declaration with no configured repository prefix can fall back as repo-local scope. If any declaration belongs to another configured repository, a repo with no matching prefix receives no fallback authority. This preserves whole-repo `repo` → `**`, normalized segment-aware nested keys, empty-scope behavior, and strict foreign-repository isolation.

View File

@@ -35,6 +35,12 @@ Workspace review remediation stores only the scope revision, failing repository,
| H12 optional deployment callback | Confirmed risk | Direct `landWorkspaceTask` performs its own canonical blocker check. |
| H13 planning dispatch noise | Non-causal signal | Existing PostgreSQL planning-episode deduplication remains unchanged. |
## FN-112 linked-task-worktree regression
FN-106 correctly rejected unexplained empty obligations, but its landing capture compared `HEAD` with `entry.branch` from `entry.worktreePath`. In production that path is the live linked task worktree, where both names normally resolve to the same task tip. The self-comparison made reviewed changes look empty and produced `Workspace merge has no evidenced landing obligations`.
Landing now uses one range for all evidence: the immutable acquisition `baseCommitSha` to the persisted task branch. Legacy entries without that SHA resolve their recorded repository target and derive a merge base against that branch. This matches Code Review's acquisition-base-to-linked-`HEAD` fingerprint while retaining the fail-closed readiness rule for unreadable bases, stale review fingerprints, out-of-scope work, duplicates, net-zero branches, and unexplained emptiness. Real-Git lifecycle coverage keeps a registered linked task worktree alive rather than substituting the main checkout, and proves exactly-once integration advancement, durable per-repository and aggregate landing proof, and terminal finalization.
## Regression coverage
Run the targeted engine workspace merger, self-healing, checkout-guard, review-routing, lease, real-Git slow, PostgreSQL workspace, CLI, and dashboard consumer tests listed in FN-106. The symptom fixture covers a pre-baseline same-ID commit, a previously landed scoped repository, an all-landed second pass, failed Code Review admission, and recreated recovery owners.
Run the targeted engine workspace merger, self-healing, checkout-guard, review-routing, lease, real-Git slow, PostgreSQL workspace, CLI, and dashboard consumer tests listed in FN-106. The symptom fixture covers a pre-baseline same-ID commit, a previously landed scoped repository, an all-landed second pass, failed Code Review admission, recreated recovery owners, and the live linked-task-worktree shape repaired by FN-112.

View File

@@ -37,6 +37,8 @@ export interface WorkspaceFixture {
repoPath(rel: string): string;
/** Run a git command inside a sub-repo. */
git(rel: string, command: string): string;
/** Create a live linked task worktree and capture its immutable acquisition baseline. */
createLinkedTaskWorktree(rel: string, branch: string): { worktreePath: string; baseCommitSha: string };
/** Remove all on-disk fixture state. */
cleanup(): void;
}
@@ -62,6 +64,19 @@ export async function createWorkspaceFixture(
repos,
repoPath: (rel: string) => path.join(rootDir, rel),
git: (rel: string, command: string) => git(path.join(rootDir, rel), command),
/*
FNXC:WorkspaceMergeEvidence 2026-08-21-17:33:
FN-112 needs real-Git coverage where the persisted path is the live task worktree and HEAD
equals its task branch. Main-checkout stand-ins hid the invalid self-comparison that erased
merge evidence, so this fixture captures the acquisition baseline before the task commit.
*/
createLinkedTaskWorktree: (rel: string, branch: string) => {
const repoDir = path.join(rootDir, rel);
const worktreePath = path.join(rootDir, `.task-worktree-${rel.replace(/[^a-z0-9]+/gi, "-")}`);
const baseCommitSha = git(repoDir, "git rev-parse HEAD");
git(repoDir, `git worktree add -b ${branch} ${worktreePath} HEAD`);
return { worktreePath, baseCommitSha };
},
cleanup: () => rmSync(rootDir, { recursive: true, force: true }),
};
}

View File

@@ -120,7 +120,7 @@ function makeTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial
.filter(([repo]) => scopedRepositories.includes(repo))
.map(([repo, entry]) => {
const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim();
const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" });
const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" });
return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }];
}));
const task = {
@@ -201,6 +201,19 @@ function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: s
fx.git(repoRel, `git worktree remove --force ${wt}`);
}
function addLinkedTaskWorktreeWithEdit(
fx: WorkspaceFixture,
repoRel: string,
content: string,
): { worktreePath: string; branch: string; baseCommitSha: string } {
const linked = fx.createLinkedTaskWorktree(repoRel, BRANCH);
configureIdentity(linked.worktreePath);
writeFileSync(path.join(linked.worktreePath, "feature.txt"), content, "utf-8");
execSync("git add feature.txt", { cwd: linked.worktreePath, stdio: "pipe" });
execSync(`git commit -m "feat(${TASK_ID}): linked task worktree feature in ${repoRel}"`, { cwd: linked.worktreePath, stdio: "pipe" });
return { ...linked, branch: BRANCH };
}
/** 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);
@@ -249,8 +262,8 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase
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 repoA = addLinkedTaskWorktreeWithEdit(fx, "repo-a", "a feature\n");
const repoB = addLinkedTaskWorktreeWithEdit(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");
@@ -263,8 +276,8 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase
const store = createStore([
makeTask({
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
"repo-a": repoA,
"repo-b": repoB,
}),
]);
const task = store.tasks.get(TASK_ID)!;
@@ -289,6 +302,12 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase
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"));
const finalizedTask = store.tasks.get(TASK_ID)!;
expect(finalizedTask.mergeDetails?.workspaceLandedShas).toEqual({
"repo-a": persisted["repo-a"].landedSha,
"repo-b": persisted["repo-b"].landedSha,
});
expect(finalizedTask.mergeDetails?.mergeConfirmed).toBe(true);
// Finalize EXACTLY once.
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
@@ -387,7 +406,7 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase
const repoB = recoveringTask.workspaceWorktrees!["repo-b"];
const repoBMergeBase = execSync(`git merge-base HEAD ${repoB.branch}`, { cwd: repoB.worktreePath, encoding: "utf8" }).trim();
recoveringTask.repositoryScope!.reviewEvidence!["repo-b"] = {
fingerprint: createHash("sha256").update(execSync(`git diff --binary ${repoB.baseCommitSha ?? repoBMergeBase}..HEAD`, { cwd: repoB.worktreePath, encoding: "utf8" })).digest("hex"),
fingerprint: createHash("sha256").update(execSync(`git diff --binary ${repoB.baseCommitSha ?? repoBMergeBase}..${repoB.branch}`, { cwd: repoB.worktreePath, encoding: "utf8" })).digest("hex"),
approvedAt: new Date().toISOString(),
};

View File

@@ -53,7 +53,7 @@ function storeFor(task: Task, scope: string[]): TaskStore & { updates: Array<Rec
function reviewEvidence(workspaceWorktrees: NonNullable<Task["workspaceWorktrees"]>): NonNullable<Task["repositoryScope"]>["reviewEvidence"] {
return Object.fromEntries(Object.entries(workspaceWorktrees).map(([repo, entry]) => {
const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim();
const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" });
const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" });
return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }];
}));
}

View File

@@ -104,6 +104,24 @@ function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: s
fx.git(repoRel, `git worktree remove --force ${worktreePath}`);
}
/** Create production-shaped evidence: the persisted path remains a live task worktree at its branch tip. */
function addRegisteredTaskWorktreeWithEdit(
fx: WorkspaceFixture,
repoRel: string,
content: string,
): { worktreePath: string; baseCommitSha: string } {
const repoDir = fx.repoPath(repoRel);
// Keep the linked checkout outside its main checkout so the production dirty-root guard stays meaningful.
const worktreePath = path.join(fx.rootDir, `.task-worktree-${repoRel}`);
const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD");
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
configureIdentity(worktreePath);
writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8");
execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" });
execSync(`git commit -m "feat(${TASK_ID}): linked task worktree feature"`, { cwd: worktreePath, stdio: "pipe" });
return { worktreePath, baseCommitSha };
}
/**
* FN-8141 shape: a `fusion/<id>` branch that committed work then REVERTED it — AHEAD of the
* integration tip (two real commits) but net-zero, so its tip is NOT an ancestor of main and the
@@ -184,7 +202,7 @@ function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task {
// merge-boundary fingerprint that production requires from Code Review.
reviewEvidence: Object.fromEntries(Object.entries(workspaceWorktrees ?? {}).map(([repo, entry]) => {
const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim();
const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" });
const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" });
return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }];
})),
},
@@ -240,6 +258,31 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => {
expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1);
});
it("lands reviewed work from a live linked task worktree baseline", async () => {
fx = await createWorkspaceFixture(["repo-a"]);
const linked = addRegisteredTaskWorktreeWithEdit(fx, "repo-a", "linked feature\n");
const integrationTipBefore = fx.git("repo-a", "git rev-parse refs/heads/main");
const store = createStore();
const task = makeTask({
"repo-a": { worktreePath: linked.worktreePath, branch: BRANCH, baseCommitSha: linked.baseCommitSha },
});
// The production shape that regressed: HEAD and the branch name resolve to the same task tip.
expect(execSync("git rev-parse HEAD", { cwd: linked.worktreePath, encoding: "utf8" }).trim())
.toBe(execSync(`git rev-parse ${BRANCH}`, { cwd: linked.worktreePath, encoding: "utf8" }).trim());
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(result.repos[0]?.error).toBeUndefined();
expect(result.repos).toMatchObject([{ repo: "repo-a", status: "landed" }]);
expect(result.allLanded).toBe(true);
expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(integrationTipBefore);
expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]);
});
it("reuses reconciled review findings for a workspace sub-repository", async () => {
fx = await createWorkspaceFixture(["repo-a"]);
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");

View File

@@ -2152,11 +2152,32 @@ export async function landWorkspaceTask(
.sort(([left], [right]) => left.localeCompare(right))) {
if (!entry.branch) throw new Error(`Workspace repository ${repoRel} has no task branch for fresh merge evidence`);
try {
const { stdout: mergeBase } = await execFileAsync("git", ["merge-base", "HEAD", entry.branch], { cwd: entry.worktreePath, encoding: "utf8" });
const { stdout } = await execFileAsync("git", ["diff", "--name-only", `${mergeBase.trim()}..${entry.branch}`], { cwd: entry.worktreePath, encoding: "utf8" });
/*
FNXC:WorkspaceMergeEvidence 2026-08-21-17:33:
FN-112 requires landing evidence to compare the immutable acquisition baseline with the
persisted task branch. A linked task worktree has HEAD checked out at entry.branch, so the
former HEAD-to-entry.branch comparison self-compared the same commit and incorrectly erased
reviewed work. The range below intentionally matches Code Review's baseCommitSha..HEAD range.
*/
const comparisonBase = entry.baseCommitSha
? await git(["rev-parse", "--verify", `${entry.baseCommitSha}^{commit}`], entry.worktreePath)
: await (async () => {
const baseResolution = await resolveWorkspaceRepoBaseBranch({
mode: "recorded",
repoRootDir: join(workspaceRootDir, repoRel),
repoRelPath: repoRel,
task,
settings,
recordedBaseBranch: entry.baseBranch,
});
return git(["merge-base", baseResolution.branch, entry.branch!], entry.worktreePath);
})();
if (!comparisonBase) throw new Error("comparison base is empty");
const comparisonRange = `${comparisonBase}..${entry.branch}`;
const { stdout } = await execFileAsync("git", ["diff", "--name-only", comparisonRange], { cwd: entry.worktreePath, encoding: "utf8" });
const files = stdout.split("\n").map((file) => file.trim()).filter(Boolean);
if (files.length > 0) {
const { stdout: diffContent } = await execFileAsync("git", ["diff", "--binary", `${entry.baseCommitSha ?? mergeBase.trim()}..HEAD`], { cwd: entry.worktreePath, encoding: "utf8" });
const { stdout: diffContent } = await execFileAsync("git", ["diff", "--binary", comparisonRange], { cwd: entry.worktreePath, encoding: "utf8" });
mergeBoundaryFingerprints[repoRel] = createHash("sha256").update(diffContent).digest("hex");
}
if (files.length > 0 && !confirmedScope?.has(repoRel)) {
@@ -2165,7 +2186,7 @@ export async function landWorkspaceTask(
if (files.length > 0) mergeBoundaryModifiedRepositories.add(repoRel);
mergeBoundaryModifiedFiles.push(...files.map((file) => `${repoRel}/${file}`));
if (files.length === 0) {
const { stdout: aheadCount } = await execFileAsync("git", ["rev-list", "--count", `HEAD..${entry.branch}`], { cwd: entry.worktreePath, encoding: "utf8" });
const { stdout: aheadCount } = await execFileAsync("git", ["rev-list", "--count", comparisonRange], { cwd: entry.worktreePath, encoding: "utf8" });
if (Number(aheadCount.trim()) > 0) netZeroBranchRepositories.add(repoRel);
}
}