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,
|
CentralCore,
|
||||||
AgentStore,
|
AgentStore,
|
||||||
PluginLoader,
|
PluginLoader,
|
||||||
assertNotWorkspaceTaskMerge,
|
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
getEnabledPiExtensionPaths,
|
getEnabledPiExtensionPaths,
|
||||||
isEphemeralAgent,
|
isEphemeralAgent,
|
||||||
@@ -43,6 +42,7 @@ import {
|
|||||||
} from "@fusion/dashboard";
|
} from "@fusion/dashboard";
|
||||||
import {
|
import {
|
||||||
runAiMerge,
|
runAiMerge,
|
||||||
|
landWorkspaceTask,
|
||||||
MissionAutopilot,
|
MissionAutopilot,
|
||||||
MissionExecutionLoop,
|
MissionExecutionLoop,
|
||||||
HeartbeatMonitor,
|
HeartbeatMonitor,
|
||||||
@@ -1305,11 +1305,31 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
// aiMergeTask is soft-deprecated.
|
// aiMergeTask is soft-deprecated.
|
||||||
//
|
//
|
||||||
const onMergeImpl = async (taskId: string) => {
|
const onMergeImpl = async (taskId: string) => {
|
||||||
// FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0).
|
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
|
||||||
// Reject workspace-mode tasks before any merge work; per-repo merge lands in
|
// Dashboard merge button (UI-only mode). A workspace-mode task routes through
|
||||||
// master-plan U6, which removes this guard.
|
// 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);
|
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();
|
const settings = await store.getSettings();
|
||||||
if (getMergeStrategy(settings) === "pull-request") {
|
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 { 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 } from "@fusion/engine";
|
import { runAiMerge, landWorkspaceTask } from "@fusion/engine";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||||
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
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`);
|
console.log(`\n Merging ${id} with AI...\n`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0).
|
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
|
||||||
// Reject workspace-mode tasks before any merge work; per-repo merge lands in
|
// User-triggered `fn task merge`. A workspace-mode task routes through the
|
||||||
// master-plan U6, which removes this guard.
|
// ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own
|
||||||
// FNXC:MergerUnification 2026-06-21-19:05: unified onto runAiMerge (U0).
|
// LOCAL integration ref, no push) instead of throwing — manual merge works in
|
||||||
// The guard lives INSIDE this try so its throw renders via the formatted
|
// Phase C (user decision). U0's R7 throw is replaced here by routing; the
|
||||||
// ` ✗ ...` output below instead of the generic top-level bin.ts handler.
|
// engine chokepoint + store.mergeTask/aiMergeTask keep throwing.
|
||||||
const mergeTaskRecord = await store.getTask(id).catch(() => null);
|
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, {
|
const result = await runAiMerge(store, projectPath, id, {
|
||||||
onAgentText: (delta) => process.stdout.write(delta),
|
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
|
// 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).
|
// (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge).
|
||||||
export { runAiMerge } from "./merger-ai.js";
|
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 {
|
export {
|
||||||
resolveMergePolicy,
|
resolveMergePolicy,
|
||||||
type ResolvedMergePolicy,
|
type ResolvedMergePolicy,
|
||||||
|
|||||||
@@ -945,6 +945,205 @@ export async function landSquash(input: {
|
|||||||
return { outcome: "advanced", localSync: "stash-ff-conflict" };
|
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
|
// Orchestrator
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1055,165 +1254,215 @@ export async function runAiMerge(
|
|||||||
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
||||||
|
|
||||||
await setStatus("merging");
|
await setStatus("merging");
|
||||||
try {
|
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1):
|
||||||
const pruned = await pruneExistingAiMergeWorktrees(taskId, projectRootDir, audit, log, settings);
|
// runAiMerge is now the SINGLE-REPO caller of the extracted `landOneRepo`. It
|
||||||
if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`);
|
// builds the same per-task context it always built and lands the project root
|
||||||
} catch (err: unknown) {
|
// once; the task-global finalization below (empty no-op / no-commits demote /
|
||||||
await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`);
|
// finalizeMerged) is unchanged byte-for-byte — only the inline clean-room land
|
||||||
}
|
// loop moved into `landOneRepo` so `landWorkspaceTask` can reuse it per sub-repo.
|
||||||
let advanceRetries = 0;
|
const landResult = await landOneRepo(store, projectRootDir, branch, integrationBranch, {
|
||||||
while (true) {
|
taskId, settings, audit, log, setStatus, maxPasses,
|
||||||
throwIfAborted(options.signal, taskId);
|
mergeAgent, reviewAgent, stashResolveAgent,
|
||||||
const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], projectRootDir);
|
includeTaskId, trailers, taskTitle, signal: options.signal,
|
||||||
|
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Clean-room worktree at the integration tip.
|
if (landResult.outcome === "empty") {
|
||||||
let mergeRoot: string | undefined;
|
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
|
||||||
let worktreeAdded = false;
|
if (noCommitsFinalize.blocked) {
|
||||||
const registeredMergePaths = new Set<string>();
|
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
|
||||||
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()}-`));
|
|
||||||
/*
|
/*
|
||||||
* FNXC:AIMerge 2026-06-14-16:36:
|
* FNXC:Lifecycle 2026-06-14-20:02:
|
||||||
* 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.
|
* 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
|
await store.updateTask(taskId, { error: reason });
|
||||||
// `git worktree add`, so self-healing/pre-merge sweeps cannot reap a
|
await store.logEntry(
|
||||||
// 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,
|
|
||||||
taskId,
|
taskId,
|
||||||
signal: options.signal,
|
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
|
||||||
context: "for AI merge clean room",
|
JSON.stringify({
|
||||||
logger: aiMergeLog,
|
doneCount: noCommitsFinalize.doneCount,
|
||||||
log,
|
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||||
});
|
branch,
|
||||||
await audit.git({
|
integrationBranch,
|
||||||
type: "merge:ai-deps-sync",
|
lane: "ai-empty-merge",
|
||||||
target: integrationBranch,
|
}, null, 2),
|
||||||
|
);
|
||||||
|
await audit.database({
|
||||||
|
type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters<typeof audit.database>[0]["type"],
|
||||||
|
target: taskId,
|
||||||
metadata: {
|
metadata: {
|
||||||
taskId,
|
reason,
|
||||||
tipSha,
|
doneCount: noCommitsFinalize.doneCount,
|
||||||
mergeRoot: canonicalMergeRoot,
|
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||||
installCommand: depsSyncResult.installCommand,
|
branch,
|
||||||
configured: depsSyncResult.configured,
|
integrationBranch,
|
||||||
skipped: depsSyncResult.skipped,
|
lane: "ai-empty-merge",
|
||||||
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)"}`);
|
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).
|
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false });
|
||||||
const squashSha = await mergeAndReview({
|
}
|
||||||
mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId,
|
|
||||||
maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!squashSha) {
|
// ---------------------------------------------------------------------------
|
||||||
// Branch had no net changes vs the tip — nothing to land.
|
// Workspace-mode per-repo merge loop (Phase C U1)
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4 + 5. Land the squash on the target branch and sync the user's
|
/** Per-repo land outcome inside a workspace task, tagged with its sub-repo. */
|
||||||
// checkout (AI reconciles a conflicting restore).
|
export interface WorkspaceRepoLandResult {
|
||||||
await setStatus("landing");
|
/** The sub-repo's relative path (the `workspaceWorktrees` key). */
|
||||||
const landed = await landSquash({
|
repo: string;
|
||||||
projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit,
|
/** Absolute path to the sub-repo's main checkout (where the ref advanced). */
|
||||||
resolveConflicts: stashResolveAgent,
|
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,
|
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
|
||||||
});
|
});
|
||||||
if (landed.outcome === "concurrent") {
|
if (landResult.outcome === "landed") {
|
||||||
if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) {
|
repos.push({
|
||||||
advanceRetries++;
|
repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch,
|
||||||
await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`);
|
status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync,
|
||||||
continue; // rebuild the clean room on the new tip
|
});
|
||||||
}
|
} else {
|
||||||
throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`);
|
repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "empty" });
|
||||||
}
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
} 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: {
|
async function mergeAndReview(input: {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
ResearchSynthesisRequest,
|
ResearchSynthesisRequest,
|
||||||
ResearchSynthesisResult,
|
ResearchSynthesisResult,
|
||||||
} from "@fusion/core";
|
} 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 { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
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 { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
|
||||||
import type { RoutineRunner } from "./routine-runner.js";
|
import type { RoutineRunner } from "./routine-runner.js";
|
||||||
import { sweepStaleAutostashes, VerificationError } from "./merger.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 { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js";
|
||||||
import { PRIORITY_MERGE } from "./concurrency.js";
|
import { PRIORITY_MERGE } from "./concurrency.js";
|
||||||
import { runtimeLog } from "./logger.js";
|
import { runtimeLog } from "./logger.js";
|
||||||
@@ -2287,17 +2287,44 @@ export class ProjectEngine {
|
|||||||
this.activeMergeSession = session;
|
this.activeMergeSession = session;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// FNXC:Workspace 2026-06-21-19:40:
|
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
|
||||||
// R7 merge-boundary guard (master-plan U0). Reject workspace-mode
|
// Engine merge dispatch door. A workspace-mode task (non-empty
|
||||||
// tasks BEFORE any git work — they need the per-repo merge loop that
|
// `workspaceWorktrees`) routes to the per-repo merge loop
|
||||||
// lands in master-plan U6 (which removes this guard). Load the task
|
// `landWorkspaceTask` (Phase C U1) instead of the singular runAiMerge —
|
||||||
// here so the dispatch shares the one predicate in @fusion/core.
|
// each sub-repo lands on its own LOCAL integration ref, no push. The
|
||||||
// This door is a FAST-FAIL only: a getTask failure is swallowed to null
|
// U0 R7 throw is REPLACED by this routing (the runAiMerge chokepoint
|
||||||
// and the guard is skipped, but the unconditional chokepoint guard inside
|
// + store.mergeTask/aiMergeTask keep throwing as defense-in-depth).
|
||||||
// runAiMerge (which re-reads the task) is the authoritative enforcement,
|
// FAST-FAIL note preserved: a getTask failure is swallowed to null and
|
||||||
// so a transient read failure here cannot let a workspace task reach git work.
|
// 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);
|
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:
|
// FNXC:MergerUnification 2026-06-21-19:05:
|
||||||
// Master-plan U0 collapsed the merge dispatch: `runAiMerge` (the
|
// Master-plan U0 collapsed the merge dispatch: `runAiMerge` (the
|
||||||
|
|||||||
Reference in New Issue
Block a user