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:
12
.changeset/workspace-phase-c-u1-per-repo-merge-loop.md
Normal file
12
.changeset/workspace-phase-c-u1-per-repo-merge-loop.md
Normal 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.
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
292
packages/engine/src/__tests__/workspace-merger.test.ts
Normal file
292
packages/engine/src/__tests__/workspace-merger.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user