feat(workspace): Phase C U1 — per-repo merge loop (landOneRepo + landWorkspaceTask)

Extracts the per-repo land mechanics out of runAiMerge's inline clean-room
closure into an exported landOneRepo(store, repoRootDir, branch, integrationBranch,
ctx): pre-merge prune (rooted at the sub-repo), the clean-room temp worktree,
mergeAndReview, landSquash, and the CAS concurrent-advance retry that advances ONE
local integration ref — no remote push. runAiMerge is rewired as the single-repo
caller (its task-global finalization unchanged); the merger-ai suite (56 tests)
stays green as the byte-for-byte oracle.

landWorkspaceTask loops a workspace task's acquired sub-repos (sorted keys),
re-resolving each repo's integration branch with the shared override stripped
({...settings, integrationBranch: undefined, baseBranch: undefined}) so each
sub-repo lands on its own origin/HEAD, calls landOneRepo per repo, and aggregates
repo-tagged results — land-as-you-go on each repo's LOCAL ref (D2/D5). It does NOT
finalize/move the task (finalize-once + landed-tracking + idempotent retry are U2).

Door routing (KTD2): the engine dispatch and the user-facing CLI `fn task merge`
+ dashboard merge doors route workspace tasks to landWorkspaceTask so manual merge
works; store.mergeTask, aiMergeTask, and the runAiMerge chokepoint guard keep
throwing WorkspaceTaskMergeError as defense-in-depth.

New two-repo fixture tests: both repos land + no-push assertion, per-repo
override-stripped resolution onto distinct branches, repo-B conflict partial land
(task not moved), defense-in-depth throws. Gate green: typecheck, lint, build,
test:gate (649+58).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 23:31:43 -07:00
parent edd79a87dc
commit 744ed098a5
7 changed files with 798 additions and 170 deletions

View File

@@ -0,0 +1,12 @@
---
"@runfusion/fusion": minor
---
Workspace mode Phase C (U1): per-repo merge loop. Extract `landOneRepo` from the
`runAiMerge` clean-room land closure (single-repo behavior unchanged) and add
`landWorkspaceTask`, which lands each acquired sub-repo's `fusion/<id>` branch onto
that repo's OWN local integration ref (re-resolved per repo with overrides stripped),
land-as-you-go with no remote push. The engine merge dispatch and the user-facing
CLI/dashboard merge doors now route workspace tasks through this loop instead of
throwing; `store.mergeTask`, `aiMergeTask`, and the `runAiMerge` chokepoint keep
throwing `WorkspaceTaskMergeError` as defense-in-depth.

View File

@@ -9,7 +9,6 @@ import {
CentralCore,
AgentStore,
PluginLoader,
assertNotWorkspaceTaskMerge,
getTaskMergeBlocker,
getEnabledPiExtensionPaths,
isEphemeralAgent,
@@ -43,6 +42,7 @@ import {
} from "@fusion/dashboard";
import {
runAiMerge,
landWorkspaceTask,
MissionAutopilot,
MissionExecutionLoop,
HeartbeatMonitor,
@@ -1305,11 +1305,31 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// aiMergeTask is soft-deprecated.
//
const onMergeImpl = async (taskId: string) => {
// FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0).
// Reject workspace-mode tasks before any merge work; per-repo merge lands in
// master-plan U6, which removes this guard.
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// Dashboard merge button (UI-only mode). A workspace-mode task routes through
// the ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its
// own LOCAL integration ref, no push) instead of throwing — manual merge works in
// Phase C (user decision). U0's R7 throw is replaced here by routing; the engine
// chokepoint + store.mergeTask/aiMergeTask keep throwing as defense-in-depth.
const mergeTask = await store.getTask(taskId).catch(() => null);
if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask);
const isWorkspaceMerge =
!!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0;
if (isWorkspaceMerge) {
const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, {
agentStore,
});
const latest = await store.getTask(taskId).catch(() => mergeTask!);
// U1 does not finalize the workspace task (finalize-once move-to-done is U2);
// report merged=false until then.
return {
task: latest ?? mergeTask!,
branch: getTaskBranchName(taskId),
merged: false,
worktreeRemoved: false,
branchDeleted: false,
error: workspaceResult.allLanded ? undefined : "partial workspace land — see task log",
};
}
const settings = await store.getSettings();
if (getMergeStrategy(settings) === "pull-request") {

View File

@@ -1,5 +1,5 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, assertNotWorkspaceTaskMerge, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { runAiMerge } from "@fusion/engine";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { runAiMerge, landWorkspaceTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
@@ -851,14 +851,32 @@ export async function runTaskMerge(id: string, projectName?: string) {
console.log(`\n Merging ${id} with AI...\n`);
try {
// FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0).
// Reject workspace-mode tasks before any merge work; per-repo merge lands in
// master-plan U6, which removes this guard.
// FNXC:MergerUnification 2026-06-21-19:05: unified onto runAiMerge (U0).
// The guard lives INSIDE this try so its throw renders via the formatted
// ` ✗ ...` output below instead of the generic top-level bin.ts handler.
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// User-triggered `fn task merge`. A workspace-mode task routes through the
// ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own
// LOCAL integration ref, no push) instead of throwing — manual merge works in
// Phase C (user decision). U0's R7 throw is replaced here by routing; the
// engine chokepoint + store.mergeTask/aiMergeTask keep throwing.
const mergeTaskRecord = await store.getTask(id).catch(() => null);
if (mergeTaskRecord) assertNotWorkspaceTaskMerge(mergeTaskRecord);
const isWorkspaceMerge =
!!mergeTaskRecord?.workspaceWorktrees && Object.keys(mergeTaskRecord.workspaceWorktrees).length > 0;
if (isWorkspaceMerge) {
const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, {
onAgentText: (delta) => process.stdout.write(delta),
});
console.log();
for (const repo of workspaceResult.repos) {
const label =
repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}`
: repo.status === "empty" ? "no net changes"
: `failed: ${repo.error ?? "unknown"}`;
console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`);
}
// U1 does not move the workspace task to done (finalize-once is U2).
console.log(`\n ${workspaceResult.allLanded ? "✓ All sub-repos landed" : "✗ Partial land — see failures above"} (task remains in review until U2)\n`);
if (!workspaceResult.allLanded) process.exit(1);
return;
}
const result = await runAiMerge(store, projectPath, id, {
onAgentText: (delta) => process.stdout.write(delta),

View File

@@ -0,0 +1,292 @@
/*
FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1/KTD2):
Per-repo workspace merge-loop tests. They drive the REAL `landWorkspaceTask` /
`landOneRepo` against a REAL two-repo git fixture under a NON-git workspace root
(createWorkspaceFixture), so a leaked rootDir git preflight would actually fail and a
shared clean-room root would race. Real git is used only where the invariant requires
it (the local-ref advance, the no-push assertion); the AI merge/review agents are
injected (deps) so NO real AI calls happen and the squash is produced by a plain
`git merge --squash` inside the clean room — no mock-the-world child_process.
Coverage (FN-5893 surfaces):
- happy: two acquired repos both clean → BOTH local integration refs advance against
each repo's own resolved branch; NO remote ref/push happened; result tags both.
- per-repo resolution: repos with DIFFERENT origin/HEAD integration branches → each
lands on its own (override-stripping works, not a shared branch).
- partial: a conflict in repo B → repo A lands (landedSha recorded); B reports the
failure; the task is NOT moved done (no finalizeTask call).
- defense-in-depth: store.mergeTask / aiMergeTask with a workspace task → still throw
WorkspaceTaskMergeError.
The single-repo runAiMerge regression lives in the existing merger-ai*.test.ts (the
extraction is byte-for-byte; runAiMerge is landOneRepo's single-repo caller).
*/
import { afterEach, 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 { Task, TaskStore } from "@fusion/core";
import { assertNotWorkspaceTaskMerge } from "@fusion/core";
import { landWorkspaceTask } from "../merger-ai.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip;
const TASK_ID = "FN-2001";
const BRANCH = "fusion/fn-2001";
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" });
}
interface RecordingStore extends EventEmitter {
moveTaskCalls: Array<{ id: string; column: string }>;
emitted: Array<{ event: string; payload: unknown }>;
}
function createStore(settings: Record<string, unknown> = {}): TaskStore & RecordingStore {
const emitter = new EventEmitter();
const moveTaskCalls: Array<{ id: string; column: string }> = [];
const emitted: Array<{ event: string; payload: unknown }> = [];
const realEmit = emitter.emit.bind(emitter);
const store = Object.assign(emitter, {
moveTaskCalls,
emitted,
getSettings: vi.fn().mockResolvedValue({ autoMerge: false, ...settings }),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn((id: string, column: string) => {
moveTaskCalls.push({ id, column });
return Promise.resolve({ id, column } as Task);
}),
upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined),
accumulateTokenUsage: vi.fn().mockResolvedValue(undefined),
emit: (event: string, payload?: unknown) => {
emitted.push({ event, payload });
return realEmit(event, payload);
},
}) as unknown as TaskStore & RecordingStore;
return store;
}
/**
* Add a real `fusion/<id>` worktree to a sub-repo with one own commit that EDITS the
* README the integration tip already has, then remove the worktree (we only need the
* branch ref). Returns the branch name. By default the edit is non-conflicting.
*/
function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void {
const repoDir = fx.repoPath(repoRel);
const worktreePath = path.join(repoDir, ".wt-branch");
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}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" });
fx.git(repoRel, `git worktree remove --force ${worktreePath}`);
}
/** 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);
// Task branch edits README on a new commit.
const worktreePath = path.join(repoDir, ".wt-conflict");
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
configureIdentity(worktreePath);
writeFileSync(path.join(worktreePath, "README.md"), "# branch-side change\n", "utf-8");
execSync("git add README.md", { cwd: worktreePath, stdio: "pipe" });
execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: worktreePath, stdio: "pipe" });
fx.git(repoRel, `git worktree remove --force ${worktreePath}`);
// Integration tip (main) diverges with a conflicting README edit.
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"');
}
/** 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.
}
// If there are unresolved conflicts, throw so landOneRepo surfaces a failure.
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");
}
// Nothing staged (already up to date) → leave HEAD unchanged (empty merge).
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";
function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task {
return {
id: TASK_ID,
title: "Workspace merge task",
description: "",
column: "in-review",
branch: BRANCH,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
workspaceWorktrees,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
}
describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => {
let fx: WorkspaceFixture;
afterEach(() => fx?.cleanup());
it("happy: both clean repos advance their OWN local integration ref with NO push", async () => {
fx = await createWorkspaceFixture(["repo-a", "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");
const store = createStore();
const task = makeTask({
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
});
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(result.allLanded).toBe(true);
expect(result.repos.map((r) => r.repo).sort()).toEqual(["repo-a", "repo-b"]);
for (const r of result.repos) expect(r.status).toBe("landed");
// Each repo's LOCAL integration ref advanced (main moved off its prior tip).
const tipAAfter = fx.git("repo-a", "git rev-parse refs/heads/main");
const tipBAfter = fx.git("repo-b", "git rev-parse refs/heads/main");
expect(tipAAfter).not.toBe(tipABefore);
expect(tipBAfter).not.toBe(tipBBefore);
// No remote ref / no push: the fixture repos have no remotes at all.
for (const repo of ["repo-a", "repo-b"]) {
const remotes = fx.git(repo, "git remote").trim();
expect(remotes).toBe("");
const remoteRefs = execSync("git for-each-ref refs/remotes", { cwd: fx.repoPath(repo), encoding: "utf-8" }).trim();
expect(remoteRefs).toBe("");
}
// U1 does NOT move the task to done.
expect(store.moveTaskCalls).toHaveLength(0);
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false);
});
it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => {
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
// Give each repo a different default integration branch via a bare origin whose
// HEAD points at that branch. landWorkspaceTask strips integrationBranch/baseBranch
// overrides, so each repo resolves origin/HEAD independently.
for (const [repo, intBranch] of [["repo-a", "develop"], ["repo-b", "release"]] as const) {
const repoDir = fx.repoPath(repo);
fx.git(repo, `git branch ${intBranch}`);
const originDir = path.join(repoDir, "..", `${repo}-origin.git`);
execSync(`git init --bare ${originDir}`, { cwd: repoDir, stdio: "pipe" });
fx.git(repo, `git remote add origin ${originDir}`);
fx.git(repo, "git push origin --all");
execSync(`git symbolic-ref HEAD refs/heads/${intBranch}`, { cwd: originDir, stdio: "pipe" });
fx.git(repo, "git remote set-head origin -a");
// task branch off the integration branch with an edit
const wt = path.join(repoDir, ".wt");
fx.git(repo, `git worktree add -b ${BRANCH} ${wt} ${intBranch}`);
configureIdentity(wt);
writeFileSync(path.join(wt, "feature.txt"), `${repo} feature\n`, "utf-8");
execSync("git add feature.txt", { cwd: wt, stdio: "pipe" });
execSync(`git commit -m "feat(${TASK_ID}): add"`, { cwd: wt, stdio: "pipe" });
fx.git(repo, `git worktree remove --force ${wt}`);
}
const store = createStore();
const task = makeTask({
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
});
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(result.allLanded).toBe(true);
const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r]));
expect(byRepo["repo-a"].integrationBranch).toBe("develop");
expect(byRepo["repo-b"].integrationBranch).toBe("release");
// Each landed onto its OWN integration branch's local ref.
expect(byRepo["repo-a"].status).toBe("landed");
expect(byRepo["repo-b"].status).toBe("landed");
expect(fx.git("repo-a", "git rev-parse refs/heads/develop")).toBe(byRepo["repo-a"].landedSha);
expect(fx.git("repo-b", "git rev-parse refs/heads/release")).toBe(byRepo["repo-b"].landedSha);
});
it("partial: repo B conflict → repo A lands, B reports failure, task NOT moved done", 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();
const task = makeTask({
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
});
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: approveReviewAgent,
});
expect(result.allLanded).toBe(false);
const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r]));
expect(byRepo["repo-a"].status).toBe("landed");
expect(byRepo["repo-b"].status).toBe("failed");
expect(byRepo["repo-b"].error).toMatch(/conflict/i);
// Repo A landed locally (its ref advanced).
expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore);
// The task was NOT finalized/moved done on a partial land.
expect(store.moveTaskCalls).toHaveLength(0);
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false);
});
});
describe("workspace merge defense-in-depth (non-routed doors keep throwing)", () => {
it("assertNotWorkspaceTaskMerge throws WorkspaceTaskMergeError for a workspace task (store.mergeTask/aiMergeTask door)", () => {
const task = {
id: TASK_ID,
workspaceWorktrees: { "repo-a": { worktreePath: "/x/repo-a", branch: BRANCH } },
} as unknown as Task;
expect(() => assertNotWorkspaceTaskMerge(task)).toThrowError(/cannot merge until per-repo merge/i);
try {
assertNotWorkspaceTaskMerge(task);
} catch (err) {
expect((err as Error).name).toBe("WorkspaceTaskMergeError");
}
});
it("assertNotWorkspaceTaskMerge is a no-op for a single-repo task", () => {
const task = { id: TASK_ID } as unknown as Task;
expect(() => assertNotWorkspaceTaskMerge(task)).not.toThrow();
});
});

View File

@@ -190,6 +190,16 @@ export {
// FNXC:MergerUnification 2026-06-21-19:05: runAiMerge is the sole merge path
// (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge).
export { runAiMerge } from "./merger-ai.js";
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1): per-repo workspace merge loop +
// the extracted per-repo land primitive, exported for the CLI/dashboard merge doors.
export {
landWorkspaceTask,
landOneRepo,
type WorkspaceMergeResult,
type WorkspaceRepoLandResult,
type LandOneRepoResult,
type LandRepoContext,
} from "./merger-ai.js";
export {
resolveMergePolicy,
type ResolvedMergePolicy,

View File

@@ -945,6 +945,205 @@ export async function landSquash(input: {
return { outcome: "advanced", localSync: "stash-ff-conflict" };
}
// ---------------------------------------------------------------------------
// Per-repo land (extracted from runAiMerge's inline clean-room closure)
// ---------------------------------------------------------------------------
/*
FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1):
`landOneRepo` is the per-repo land mechanic extracted byte-for-byte from
`runAiMerge`'s former inline clean-room closure: pre-merge prune (rooted at THIS
repo) → mkdtemp clean room → `git worktree add --detach` → installWorktreeDependencies
→ mergeAndReview → landSquash → the concurrent-advance CAS retry loop → the
activeSessionRegistry register/unregister + cleanup-finally. It advances ONE local
integration ref (no remote push) and returns what landed. It deliberately does NOT
move the task or write task-level mergeDetails — that task-global finalization
(`finalizeMerged`/`finalizeTask`/`evaluateNoCommitsNoOpFinalize`) stays with the
caller, so the same primitive is callable per sub-repo from `landWorkspaceTask`
without finalizing the whole task per repo (KTD3).
`runAiMerge` is the SINGLE-REPO caller: it builds the same context it always built
and calls `landOneRepo` once against the project root, then runs its existing
finalization on the result. Single-repo behavior is unchanged.
*/
/** Per-task context shared by every per-repo land (agents/audit/log are bound to
* the task, not the repo). The repo-varying inputs (rootDir/branch/integrationBranch)
* are explicit `landOneRepo` args. */
export interface LandRepoContext {
taskId: string;
settings: Settings;
audit: RunAuditor;
log: (message: string) => Promise<void>;
setStatus: (status: string | null) => Promise<unknown>;
maxPasses: number;
mergeAgent: (cwd: string, prompt: string) => Promise<void>;
reviewAgent: (cwd: string, prompt: string) => Promise<string>;
stashResolveAgent: (cwd: string, prompt: string) => Promise<void>;
includeTaskId: boolean;
trailers: string[];
taskTitle?: string;
signal?: AbortSignal;
allowDirtyLocalCheckoutSync?: boolean;
}
/** What a single repo's land produced. No task move / mergeDetails — the caller
* decides task-global finalization. */
export type LandOneRepoResult =
| {
/** The branch had no net changes vs the integration tip — nothing landed. */
outcome: "empty";
tipSha: string;
integrationBranch: string;
}
| {
/** The squash landed; the local integration ref now points at `squashSha`. */
outcome: "landed";
squashSha: string;
localSync: LocalSyncOutcome;
tipSha: string;
integrationBranch: string;
};
/**
* Land `branch` onto `integrationBranch`'s LOCAL ref in `repoRootDir` via a
* repo-scoped clean room, retrying on concurrent advance. No remote push. See
* the FNXC note above for the extraction contract.
*/
export async function landOneRepo(
store: TaskStore,
repoRootDir: string,
branch: string,
integrationBranch: string,
ctx: LandRepoContext,
): Promise<LandOneRepoResult> {
const {
taskId, settings, audit, log, setStatus, maxPasses,
mergeAgent, reviewAgent, stashResolveAgent,
includeTaskId, trailers, taskTitle, signal,
} = ctx;
// Pre-merge prune is rooted at THIS sub-repo (KTD1): N per-repo clean rooms for
// one task share the `fusion-ai-merge-<taskId>-` prefix, so a prune rooted at a
// shared root could reap a sibling repo's live clean room. Rooting it at
// repoRootDir keeps each repo's prune to its own temp roots.
try {
const pruned = await pruneExistingAiMergeWorktrees(taskId, repoRootDir, audit, log, settings);
if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`);
} catch (err: unknown) {
await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`);
}
let advanceRetries = 0;
while (true) {
throwIfAborted(signal, taskId);
const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir);
// 1. Clean-room worktree at the integration tip.
let mergeRoot: string | undefined;
let worktreeAdded = false;
const registeredMergePaths = new Set<string>();
const registerMergeRoot = (pathToRegister: string): void => {
if (registeredMergePaths.has(pathToRegister)) return;
activeSessionRegistry.registerPath(pathToRegister, { taskId, kind: "ai-merge", ownerKey: `ai-merge:${taskId}` });
registeredMergePaths.add(pathToRegister);
};
try {
mergeRoot = await mkdtemp(join(resolveAiMergeRoot(repoRootDir, settings), `fusion-ai-merge-${taskId.toLowerCase()}-`));
/*
* FNXC:AIMerge 2026-06-14-16:36:
* The AI-merge clean-room directory must be created and registered inside the cleanup guard. Any terminal path or interrupt after `mkdtemp`, including active-session registration failure before `git worktree add`, must still unregister known paths and remove the `fusion-ai-merge-*` directory.
*/
// Register the repo-local clean-room path as soon as it exists, before
// `git worktree add`, so self-healing/pre-merge sweeps cannot reap a
// just-created clean room in the small window before canonical registration
// is available.
registerMergeRoot(mergeRoot);
await git(["worktree", "add", "--detach", mergeRoot, tipSha], repoRootDir);
worktreeAdded = true;
let canonicalMergeRoot = mergeRoot;
try {
canonicalMergeRoot = realpathSync(mergeRoot);
} catch {
canonicalMergeRoot = mergeRoot;
}
for (const pathToRegister of new Set([canonicalMergeRoot, mergeRoot])) {
registerMergeRoot(pathToRegister);
}
await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } });
await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`);
/*
* FNXC:AIMerge 2026-06-13-20:32:
* The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run.
*/
const depsSyncStartedAt = Date.now();
const depsSyncResult = await installWorktreeDependencies({
cwd: canonicalMergeRoot,
settings,
taskId,
signal,
context: "for AI merge clean room",
logger: aiMergeLog,
log,
});
await audit.git({
type: "merge:ai-deps-sync",
target: integrationBranch,
metadata: {
taskId,
tipSha,
mergeRoot: canonicalMergeRoot,
installCommand: depsSyncResult.installCommand,
configured: depsSyncResult.configured,
skipped: depsSyncResult.skipped,
skipReason: depsSyncResult.skipReason,
durationMs: depsSyncResult.durationMs,
},
});
await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`);
// 2 + 3. Merge + review loop (corrective passes).
const squashSha = await mergeAndReview({
mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId,
maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal,
});
if (!squashSha) {
// Branch had no net changes vs the tip — nothing to land. The caller
// decides how to finalize the (possibly multi-repo) task.
await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } });
return { outcome: "empty", tipSha, integrationBranch };
}
// 4 + 5. Land the squash on the target branch and sync the user's
// checkout (AI reconciles a conflicting restore).
await setStatus("landing");
const landed = await landSquash({
projectRootDir: repoRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit,
resolveConflicts: stashResolveAgent,
allowDirtyLocalCheckoutSync: ctx.allowDirtyLocalCheckoutSync === true,
});
if (landed.outcome === "concurrent") {
if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) {
advanceRetries++;
await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`);
continue; // rebuild the clean room on the new tip
}
throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`);
}
await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`);
return { outcome: "landed", squashSha, localSync: landed.localSync, tipSha, integrationBranch };
} finally {
for (const registeredPath of registeredMergePaths) {
activeSessionRegistry.unregisterPath(registeredPath);
}
if (mergeRoot) {
await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir: repoRootDir, worktreeAdded, audit, log });
}
}
}
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
@@ -1055,165 +1254,215 @@ export async function runAiMerge(
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
await setStatus("merging");
try {
const pruned = await pruneExistingAiMergeWorktrees(taskId, projectRootDir, audit, log, settings);
if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`);
} catch (err: unknown) {
await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`);
}
let advanceRetries = 0;
while (true) {
throwIfAborted(options.signal, taskId);
const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], projectRootDir);
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1):
// runAiMerge is now the SINGLE-REPO caller of the extracted `landOneRepo`. It
// builds the same per-task context it always built and lands the project root
// once; the task-global finalization below (empty no-op / no-commits demote /
// finalizeMerged) is unchanged byte-for-byte — only the inline clean-room land
// loop moved into `landOneRepo` so `landWorkspaceTask` can reuse it per sub-repo.
const landResult = await landOneRepo(store, projectRootDir, branch, integrationBranch, {
taskId, settings, audit, log, setStatus, maxPasses,
mergeAgent, reviewAgent, stashResolveAgent,
includeTaskId, trailers, taskTitle, signal: options.signal,
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
});
// 1. Clean-room worktree at the integration tip.
let mergeRoot: string | undefined;
let worktreeAdded = false;
const registeredMergePaths = new Set<string>();
const registerMergeRoot = (pathToRegister: string): void => {
if (registeredMergePaths.has(pathToRegister)) return;
activeSessionRegistry.registerPath(pathToRegister, { taskId, kind: "ai-merge", ownerKey: `ai-merge:${taskId}` });
registeredMergePaths.add(pathToRegister);
};
try {
mergeRoot = await mkdtemp(join(resolveAiMergeRoot(projectRootDir, settings), `fusion-ai-merge-${taskId.toLowerCase()}-`));
if (landResult.outcome === "empty") {
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
if (noCommitsFinalize.blocked) {
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
/*
* FNXC:AIMerge 2026-06-14-16:36:
* The AI-merge clean-room directory must be created and registered inside the cleanup guard. Any terminal path or interrupt after `mkdtemp`, including active-session registration failure before `git worktree add`, must still unregister known paths and remove the `fusion-ai-merge-*` directory.
* FNXC:Lifecycle 2026-06-14-20:02:
* FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done.
*/
// Register the repo-local clean-room path as soon as it exists, before
// `git worktree add`, so self-healing/pre-merge sweeps cannot reap a
// just-created clean room in the small window before canonical registration
// is available.
registerMergeRoot(mergeRoot);
await git(["worktree", "add", "--detach", mergeRoot, tipSha], projectRootDir);
worktreeAdded = true;
let canonicalMergeRoot = mergeRoot;
try {
canonicalMergeRoot = realpathSync(mergeRoot);
} catch {
canonicalMergeRoot = mergeRoot;
}
for (const pathToRegister of new Set([canonicalMergeRoot, mergeRoot])) {
registerMergeRoot(pathToRegister);
}
await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } });
await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`);
/*
* FNXC:AIMerge 2026-06-13-20:32:
* The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run.
*/
const depsSyncStartedAt = Date.now();
const depsSyncResult = await installWorktreeDependencies({
cwd: canonicalMergeRoot,
settings,
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
signal: options.signal,
context: "for AI merge clean room",
logger: aiMergeLog,
log,
});
await audit.git({
type: "merge:ai-deps-sync",
target: integrationBranch,
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
JSON.stringify({
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
integrationBranch,
lane: "ai-empty-merge",
}, null, 2),
);
await audit.database({
type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters<typeof audit.database>[0]["type"],
target: taskId,
metadata: {
taskId,
tipSha,
mergeRoot: canonicalMergeRoot,
installCommand: depsSyncResult.installCommand,
configured: depsSyncResult.configured,
skipped: depsSyncResult.skipped,
skipReason: depsSyncResult.skipReason,
durationMs: depsSyncResult.durationMs,
reason,
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
integrationBranch,
lane: "ai-empty-merge",
},
});
await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`);
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters<TaskStore["moveTask"]>[2]);
return {
task,
branch,
merged: false,
noOp: false,
ok: true,
reason,
error: reason,
worktreeRemoved: false,
branchDeleted: false,
};
}
await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`);
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true });
}
// 2 + 3. Merge + review loop (corrective passes).
const squashSha = await mergeAndReview({
mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId,
maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal,
});
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false });
}
if (!squashSha) {
// Branch had no net changes vs the tip — nothing to land.
await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } });
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
if (noCommitsFinalize.blocked) {
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
/*
* FNXC:Lifecycle 2026-06-14-20:02:
* FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done.
*/
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
JSON.stringify({
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
integrationBranch,
lane: "ai-empty-merge",
}, null, 2),
);
await audit.database({
type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters<typeof audit.database>[0]["type"],
target: taskId,
metadata: {
reason,
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
integrationBranch,
lane: "ai-empty-merge",
},
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters<TaskStore["moveTask"]>[2]);
return {
task,
branch,
merged: false,
noOp: false,
ok: true,
reason,
error: reason,
worktreeRemoved: false,
branchDeleted: false,
};
}
await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`);
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, tipSha, audit, log, { empty: true });
}
// ---------------------------------------------------------------------------
// Workspace-mode per-repo merge loop (Phase C U1)
// ---------------------------------------------------------------------------
// 4 + 5. Land the squash on the target branch and sync the user's
// checkout (AI reconciles a conflicting restore).
await setStatus("landing");
const landed = await landSquash({
projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit,
resolveConflicts: stashResolveAgent,
/** Per-repo land outcome inside a workspace task, tagged with its sub-repo. */
export interface WorkspaceRepoLandResult {
/** The sub-repo's relative path (the `workspaceWorktrees` key). */
repo: string;
/** Absolute path to the sub-repo's main checkout (where the ref advanced). */
repoRootDir: string;
/** The per-repo integration branch this repo landed onto (origin/HEAD-derived). */
integrationBranch: string;
/** The `fusion/<id>` branch that was landed. */
branch: string;
/** What happened: landed, empty (no net changes), or failed. */
status: "landed" | "empty" | "failed";
/** The squash sha when `status === "landed"`. */
landedSha?: string;
/** How the sub-repo checkout was reconciled when landed. */
localSync?: LocalSyncOutcome;
/** Failure message when `status === "failed"`. */
error?: string;
}
/** Aggregated result of a workspace task's per-repo merge loop. */
export interface WorkspaceMergeResult {
taskId: string;
repos: WorkspaceRepoLandResult[];
/** True iff every acquired sub-repo landed (or was empty) with no failure. */
allLanded: boolean;
}
/*
FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1/KTD2):
`landWorkspaceTask` replaces U0's R7 fail-fast throw with the real per-repo merge
loop. For each acquired sub-repo (iterated by SORTED relative-path key for
determinism) it lands that repo's `fusion/<id>` branch onto THAT repo's own LOCAL
integration ref via the extracted `landOneRepo` — no remote push, land-as-you-go
(settled D2/D5).
Per-repo integration branch (KTD1): `workspaceWorktrees[repo]` does NOT store the
integration branch (acquisition computes then discards it), so we re-resolve it per
repo with the SAME override-stripping acquisition used — integrationBranch/baseBranch
undefined — so each sub-repo falls through to its own origin/HEAD rather than a shared
workspace branch.
U1 scope: on a repo failure we stop the loop and return a PARTIAL result (repo A may
have landed; B reports the failure). The landed-state predicate + idempotent retry and
the finalize-task-ONCE move-to-done are U2 — `landWorkspaceTask` here deliberately does
NOT call finalizeMerged/finalizeTask or move the task. Routing the engine + CLI doors
to this loop is KTD2.
*/
export async function landWorkspaceTask(
store: TaskStore,
task: Task,
workspaceRootDir: string,
options: MergerOptions = {},
deps: AgentDeps = {},
): Promise<WorkspaceMergeResult> {
const taskId = task.id;
const settings = await store.getSettings();
const audit = createRunAuditor(store, {
runId: generateSyntheticRunId("ai-merge", taskId),
agentId: "merger",
taskId,
phase: "merge",
});
const log = async (message: string): Promise<void> => {
await store.logEntry(taskId, message, "AiMerge").catch(() => undefined);
await store.appendAgentLog(taskId, message, "text", undefined, "merger").catch(() => undefined);
};
const setStatus = (status: string | null): Promise<unknown> =>
store.updateTask(taskId, { status }).catch(() => undefined);
const maxPasses = Math.max(0, Math.trunc(settings.merger?.maxReviewPasses ?? 3));
const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt(settings.agentPrompts));
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
const includeTaskId = settings.includeTaskIdInCommit !== false;
const trailers = taskTrailers(taskId, task.lineageId);
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
const workspaceWorktrees = task.workspaceWorktrees ?? {};
// SORTED keys for deterministic land order (KTD1).
const repoKeys = Object.keys(workspaceWorktrees).sort();
const repos: WorkspaceRepoLandResult[] = [];
let allLanded = true;
await setStatus("merging");
for (const repoRel of repoKeys) {
throwIfAborted(options.signal, taskId);
const entry = workspaceWorktrees[repoRel];
const repoRootDir = join(workspaceRootDir, repoRel);
// Re-resolve THIS sub-repo's integration branch with the shared overrides
// stripped (KTD1) so each sub-repo lands on its OWN origin/HEAD, not a shared
// workspace branch.
let integrationBranch: string;
try {
integrationBranch = await resolveIntegrationBranch(
repoRootDir,
{ ...settings, integrationBranch: undefined, baseBranch: undefined },
);
} catch (err: unknown) {
const message = getErrorMessage(err);
await log(`AI merge (workspace): failed to resolve integration branch for sub-repo ${repoRel}: ${message}`);
repos.push({ repo: repoRel, repoRootDir, integrationBranch: "", branch: entry.branch, status: "failed", error: message });
allLanded = false;
break;
}
try {
const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, {
taskId, settings, audit, log, setStatus, maxPasses,
mergeAgent, reviewAgent, stashResolveAgent,
includeTaskId, trailers, taskTitle, signal: options.signal,
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
});
if (landed.outcome === "concurrent") {
if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) {
advanceRetries++;
await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`);
continue; // rebuild the clean room on the new tip
}
throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`);
}
await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`);
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, squashSha, audit, log, { empty: false });
} finally {
for (const registeredPath of registeredMergePaths) {
activeSessionRegistry.unregisterPath(registeredPath);
}
if (mergeRoot) {
await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log });
if (landResult.outcome === "landed") {
repos.push({
repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch,
status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync,
});
} else {
repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "empty" });
}
} catch (err: unknown) {
const message = getErrorMessage(err);
await log(`AI merge (workspace): sub-repo ${repoRel} land failed: ${message}`);
await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined);
repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message });
allLanded = false;
// U1: stop on first failure and return a partial result. U2 adds the landed
// predicate + idempotent retry so a re-run skips the already-landed repos.
break;
}
}
await setStatus(null);
// TODO(Phase C U2): when `allLanded` and every acquired repo landed, finalize the
// task ONCE (finalizeTask / move-done) — NEVER per repo. Until U2's landed
// predicate + idempotent retry land, this loop leaves the task in place; the
// engine dispatch (KTD2) does not move it on a partial result.
return { taskId, repos, allLanded };
}
async function mergeAndReview(input: {

View File

@@ -13,7 +13,7 @@ import type {
ResearchSynthesisRequest,
ResearchSynthesisResult,
} from "@fusion/core";
import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
@@ -31,7 +31,7 @@ import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-st
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
import type { RoutineRunner } from "./routine-runner.js";
import { sweepStaleAutostashes, VerificationError } from "./merger.js";
import { runAiMerge } from "./merger-ai.js";
import { runAiMerge, landWorkspaceTask } from "./merger-ai.js";
import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js";
import { PRIORITY_MERGE } from "./concurrency.js";
import { runtimeLog } from "./logger.js";
@@ -2287,17 +2287,44 @@ export class ProjectEngine {
this.activeMergeSession = session;
},
};
// FNXC:Workspace 2026-06-21-19:40:
// R7 merge-boundary guard (master-plan U0). Reject workspace-mode
// tasks BEFORE any git work — they need the per-repo merge loop that
// lands in master-plan U6 (which removes this guard). Load the task
// here so the dispatch shares the one predicate in @fusion/core.
// This door is a FAST-FAIL only: a getTask failure is swallowed to null
// and the guard is skipped, but the unconditional chokepoint guard inside
// runAiMerge (which re-reads the task) is the authoritative enforcement,
// so a transient read failure here cannot let a workspace task reach git work.
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// Engine merge dispatch door. A workspace-mode task (non-empty
// `workspaceWorktrees`) routes to the per-repo merge loop
// `landWorkspaceTask` (Phase C U1) instead of the singular runAiMerge —
// each sub-repo lands on its own LOCAL integration ref, no push. The
// U0 R7 throw is REPLACED by this routing (the runAiMerge chokepoint
// + store.mergeTask/aiMergeTask keep throwing as defense-in-depth).
// FAST-FAIL note preserved: a getTask failure is swallowed to null and
// routing falls through to runAiMerge, whose chokepoint guard re-reads
// the task and is the authoritative workspace enforcement.
const mergeTask = await store.getTask(taskId).catch(() => null);
if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask);
const isWorkspaceMerge =
!!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0;
if (isWorkspaceMerge) {
// U1: land each acquired sub-repo on its own local integration ref.
// Task move-to-done (finalize once after all land) + idempotent retry
// are U2 — for now the loop returns a partial/aggregate result and the
// task is left in place.
const settings = await store.getSettings().catch(() => ({}) as Settings);
const workspaceResult = await landWorkspaceTask(
store,
mergeTask!,
cwd,
{ ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true },
);
const latest = await store.getTask(taskId).catch(() => mergeTask!);
return {
task: latest ?? mergeTask!,
branch: mergeTask!.branch ?? "",
// U1 does not finalize the task; report merged=false until U2 wires
// the finalize-once move-to-done after every repo lands.
merged: false,
noOp: !workspaceResult.repos.some((r) => r.status === "landed"),
ok: workspaceResult.allLanded,
worktreeRemoved: false,
branchDeleted: false,
} as MergeResult;
}
// FNXC:MergerUnification 2026-06-21-19:05:
// Master-plan U0 collapsed the merge dispatch: `runAiMerge` (the