FN-5782: wire branch groups into merge routing

Connect branch_group metadata through planning and merge execution for grouped integration branches.

- propagate `branchGroup` and `branchGroupName` through core store types, merge metadata, and CLI task lifecycle APIs
- add merge coordination that computes branch-group plans and routes grouped tasks via `group-merge-coordinator`
- extend finalize-plan, merger, and reliability tests to cover grouped merge routing and integration behavior
- document the architecture update and add a patch changeset for `@runfusion/fusion`

Files changed:
 .changeset/fn-5782-branch-group-merge.md           |   7 +
 docs/architecture.md                               |   2 +
 packages/cli/src/commands/__tests__/task-lifecycle.test.ts  |  64 ++++++++-
 packages/cli/src/commands/task-lifecycle.ts        |   4 +
 packages/core/src/__tests__/branch-group-store.test.ts  |  20 +++
 packages/core/src/__tests__/task-merge.test.ts     |  64 +++++++++
 packages/core/src/store.ts                         |  22 ++-
 packages/core/src/task-merge.ts                    |  26 +++-
 packages/core/src/types.ts                         |   2 +-
 packages/engine/src/__tests__/experiment-finalize-plan.test.ts |  33 ++++-
 packages/engine/src/__tests__/group-merge-coordinator.test.ts  |  62 +++++++++
 packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts | 153 +++++++++++++++++++++
 packages/engine/src/experiment/finalize-plan.ts    |  62 +++++++--
 packages/engine/src/group-merge-coordinator.ts     |  51 +++++++
 packages/engine/src/index.ts                       |   4 +
 packages/engine/src/merger.ts                      |  47 ++++++-
 16 files changed, 601 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-5782
Fusion-Task-Lineage: 0a70cfaa-2379-4955-b295-64de1f3093c8
This commit is contained in:
gsxdsm
2026-05-31 12:45:35 -07:00
parent f1aba5b534
commit 0c425788cb
16 changed files with 601 additions and 22 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ExperimentSession, ExperimentSessionRecord } from "@fusion/core";
import { buildDefaultPlan, mergePlanWithUserOverrides } from "../experiment/finalize-plan.js";
import type { BranchGroup, ExperimentSession, ExperimentSessionRecord } from "@fusion/core";
import { buildDefaultPlan, buildTaskGroupPlan, mergePlanWithUserOverrides } from "../experiment/finalize-plan.js";
import { ExperimentFinalizePlanError } from "../experiment/finalize-types.js";
function createSession(overrides: Partial<ExperimentSession> = {}): ExperimentSession {
@@ -139,6 +139,35 @@ describe("experiment finalize plan", () => {
);
});
it("builds a task-group finalize plan keyed to existing branch name", () => {
const branchGroup: BranchGroup = {
id: "BG-1",
sourceType: "mission",
sourceId: "SL-1",
branchName: "fusion/groups/sl-1",
autoMerge: false,
prState: "none",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
};
const plan = buildTaskGroupPlan({
branchGroup,
memberTasks: [
{ id: "FN-2", createdAt: "2026-01-02T00:00:00.000Z", mergeDetails: { commitSha: "c2" } },
{ id: "FN-1", createdAt: "2026-01-01T00:00:00.000Z", mergeDetails: { commitSha: "c1" } },
],
integrationBranch: "main",
mergeBaseCommit: "base",
});
expect(plan.groups).toHaveLength(1);
expect(plan.groups[0].suggestedBranchName).toBe("fusion/groups/sl-1");
expect(plan.groups[0].runRecordIds).toEqual(["FN-1", "FN-2"]);
expect(plan.groups[0].commits).toEqual(["c1", "c2"]);
});
it("falls back baseline commit to baseline run then merge-base", () => {
const baseline = runRecord("base", 1, 1, "keep", "baseline-commit");
const kept = runRecord("r1", 1, 2, "keep", "c1");

View File

@@ -0,0 +1,62 @@
import { execSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it, afterEach } from "vitest";
import { resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
const dirs: string[] = [];
function makeRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "fusion-group-route-"));
dirs.push(dir);
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
execSync("git config user.name test", { cwd: dir });
execSync("git config user.email test@example.com", { cwd: dir });
execSync("echo hi > a.txt", { cwd: dir, shell: "/bin/bash" });
execSync("git add . && git commit -m init", { cwd: dir, stdio: "ignore", shell: "/bin/bash" });
return dir;
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
describe("resolveBranchGroupMergeRouting", () => {
it("returns null for non-shared tasks", async () => {
const routing = await resolveBranchGroupMergeRouting({
task: { branchContext: { groupId: "BG-1", source: "planning", assignmentMode: "per-task-derived" } },
store: { getBranchGroup: () => null } as any,
projectDefaultBranch: "main",
});
expect(routing).toBeNull();
});
it("creates the group branch when missing", async () => {
const rootDir = makeRepo();
const branchGroup = {
id: "BG-1",
sourceType: "planning",
sourceId: "planning:x",
branchName: "fusion/groups/planning-x",
autoMerge: false,
prState: "none",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
};
const routing = await resolveBranchGroupMergeRouting({
task: { branchContext: { groupId: "BG-1", source: "planning", assignmentMode: "shared" } },
store: { getBranchGroup: () => branchGroup } as any,
projectDefaultBranch: "main",
rootDir,
});
expect(routing?.mergeTarget.branch).toBe(branchGroup.branchName);
const branch = execSync(`git rev-parse --verify refs/heads/${branchGroup.branchName}`, { cwd: rootDir, encoding: "utf8" }).trim();
expect(branch).toMatch(/^[a-f0-9]{40}$/);
});
});

View File

@@ -0,0 +1,153 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { type TaskStore } from "@fusion/core";
import { aiMergeTask } from "../../merger.js";
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
async function stageMergeBranch(store: TaskStore, rootDir: string, taskId: string, fileName: string): Promise<void> {
const task = await store.getTask(taskId);
const branch = `fusion/${taskId.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, taskId.toLowerCase());
await store.updateTask(taskId, {
baseBranch: "",
branch,
column: "in-review",
worktree: worktreePath,
steps: (task?.steps ?? []).map((step) => ({ ...step, status: "done" as const })),
currentStep: (task?.steps ?? []).length ?? 0,
} as any);
git(rootDir, `git checkout -b ${branch}`);
await mkdir(join(rootDir, "packages/engine/src"), { recursive: true });
git(rootDir, `sh -c 'printf ${JSON.stringify(`export const ${fileName} = true;\n`)} > ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}'`);
git(rootDir, `git add ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}`);
git(rootDir, `git commit -m ${JSON.stringify(`feat: add ${fileName}`)}`);
git(rootDir, "git checkout main");
store.enqueueMergeQueue(taskId);
}
describe("FN-5782 reliability interactions: branch group merge routing", () => {
it.skipIf(!hasGit)("routes shared grouped members to branch group integration branch and emits audit", async () => {
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-SHARED", settings: { testMode: true } as any });
try {
const { rootDir, store, task } = fixture;
await stageMergeBranch(store, rootDir, task.id, "fn5782Shared");
const group = store.createBranchGroup({
sourceType: "planning",
sourceId: "PS-FN5782",
branchName: "fusion/groups/fn-5782-shared",
});
await store.setTaskBranchGroup(task.id, group.id);
const beforeUpdateAt = store.getBranchGroup(group.id)!.updatedAt;
const auditSpy = vi.spyOn(store as any, "recordRunAuditEvent");
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5782Shared.ts`)).toContain("fn5782Shared");
expect(() => git(rootDir, "git show main:packages/engine/src/fn5782Shared.ts")).toThrow();
const updatedGroup = store.getBranchGroup(group.id)!;
expect(updatedGroup.status).toBe("open");
expect(updatedGroup.updatedAt).toBeGreaterThanOrEqual(beforeUpdateAt);
expect(updatedGroup.worktreePath).toBe(join(`${rootDir}-worktrees`, task.id.toLowerCase()));
expect(auditSpy).toHaveBeenCalledWith(expect.objectContaining({
domain: "git",
mutationType: "merge:branch-group-routed",
target: task.id,
metadata: expect.objectContaining({
groupId: group.id,
branchName: group.branchName,
mergeTargetBranch: group.branchName,
mergeTargetSource: "branch-group-integration",
}),
}));
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("lands two shared members of same group onto one integration branch", async () => {
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-A", settings: { testMode: true } as any });
try {
const { rootDir, store, task } = fixture;
const second = await store.createTask({
id: "FN-5782-RI-B",
title: "FN-5782-RI-B",
description: "second member",
column: "in-review",
baseBranch: "main",
branch: "fusion/fn-5782-ri-b",
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
steps: [],
} as any);
const group = store.createBranchGroup({
sourceType: "mission",
sourceId: "M-FN5782",
branchName: "fusion/groups/fn-5782-multi",
});
await stageMergeBranch(store, rootDir, task.id, "fn5782MemberA");
await stageMergeBranch(store, rootDir, second.id, "fn5782MemberB");
await store.setTaskBranchGroup(task.id, group.id);
await store.setTaskBranchGroup(second.id, group.id);
const firstResult = await aiMergeTask(store, rootDir, task.id);
const secondResult = await aiMergeTask(store, rootDir, second.id);
expect(firstResult.merged).toBe(true);
expect(secondResult.merged).toBe(true);
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5782MemberA.ts`)).toContain("fn5782MemberA");
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5782MemberB.ts`)).toContain("fn5782MemberB");
expect(() => git(rootDir, "git show main:packages/engine/src/fn5782MemberA.ts")).toThrow();
expect(() => git(rootDir, "git show main:packages/engine/src/fn5782MemberB.ts")).toThrow();
} finally {
await fixture.cleanup();
}
}, 45_000);
it.skipIf(!hasGit)("keeps ungrouped and per-task-derived members on project default without group routing audit", async () => {
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-UNGROUPED", settings: { testMode: true } as any });
try {
const { rootDir, store, task } = fixture;
await stageMergeBranch(store, rootDir, task.id, "fn5782Ungrouped");
const ungrouped = await aiMergeTask(store, rootDir, task.id);
expect(ungrouped.merged).toBe(true);
expect(git(rootDir, "git show main:packages/engine/src/fn5782Ungrouped.ts")).toContain("fn5782Ungrouped");
const second = await store.createTask({
id: "FN-5782-RI-DERIVED",
title: "FN-5782-RI-DERIVED",
description: "derived member",
column: "in-review",
baseBranch: "main",
branch: "fusion/fn-5782-ri-derived",
branchContext: { groupId: "BG-DERIVED", source: "planning", assignmentMode: "per-task-derived" },
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
steps: [],
} as any);
await stageMergeBranch(store, rootDir, second.id, "fn5782Derived");
const derived = await aiMergeTask(store, rootDir, second.id);
expect(derived.merged).toBe(true);
expect(git(rootDir, "git show main:packages/engine/src/fn5782Derived.ts")).toContain("fn5782Derived");
const routedEvents = store
.getRunAuditEvents()
.filter((event) => [task.id, second.id].includes((event.target as string) ?? "") && event.mutationType === "merge:branch-group-routed");
expect(routedEvents).toEqual([]);
} finally {
await fixture.cleanup();
}
}, 45_000);
});

View File

@@ -1,4 +1,4 @@
import type { ExperimentSession, ExperimentSessionRecord } from "@fusion/core";
import type { BranchGroup, ExperimentSession, ExperimentSessionRecord, Task } from "@fusion/core";
import {
ExperimentFinalizePlanError,
type FinalizeGroup,
@@ -7,7 +7,7 @@ import {
getRunRecordById,
} from "./finalize-types.js";
function slugifyGroupTitle(title: string): string {
export function slugifyGroupTitle(title: string): string {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
@@ -40,6 +40,18 @@ function resolveBaselineCommit(session: ExperimentSession, records: ExperimentSe
return mergeBaseCommit;
}
function buildBranchName(prefix: string, baseId: string, title: string, groupIndex: number, seenBranchNames: Set<string>): string {
const slug = slugifyGroupTitle(title);
let candidate = `${prefix}/${baseId.toLowerCase()}/${slug}-${groupIndex}`;
let bump = 2;
while (seenBranchNames.has(candidate)) {
candidate = `${prefix}/${baseId.toLowerCase()}/${slug}-${groupIndex}-${bump}`;
bump += 1;
}
seenBranchNames.add(candidate);
return candidate;
}
export function buildDefaultPlan(opts: {
session: ExperimentSession;
records: ExperimentSessionRecord[];
@@ -78,21 +90,13 @@ export function buildDefaultPlan(opts: {
let groupIndex = 1;
for (const [groupKey, group] of grouped.entries()) {
const runs = group.runs.sort((a, b) => a.seq - b.seq);
const slug = slugifyGroupTitle(group.title);
let candidate = `experiment/${opts.session.id.toLowerCase()}/${slug}-${groupIndex}`;
let bump = 2;
while (seenBranchNames.has(candidate)) {
candidate = `experiment/${opts.session.id.toLowerCase()}/${slug}-${groupIndex}-${bump}`;
bump += 1;
}
seenBranchNames.add(candidate);
groups.push({
id: groupKey,
title: group.title,
runRecordIds: runs.map((run) => run.id),
commits: runs.map((run) => run.payload.commit!).filter(Boolean),
suggestedBranchName: candidate,
suggestedBranchName: buildBranchName("experiment", opts.session.id, group.title, groupIndex, seenBranchNames),
});
groupIndex += 1;
}
@@ -108,6 +112,42 @@ export function buildDefaultPlan(opts: {
};
}
export function buildTaskGroupPlan(opts: {
branchGroup: BranchGroup;
memberTasks: Pick<Task, "id" | "createdAt" | "mergeDetails">[];
integrationBranch: string;
mergeBaseCommit: string;
}): FinalizePlan {
const warnings: string[] = [];
const orphanedRunRecordIds: string[] = [];
const sortedMembers = [...opts.memberTasks].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const commits = sortedMembers.flatMap((member) => {
const commit = member.mergeDetails?.commitSha?.trim();
if (!commit) {
orphanedRunRecordIds.push(member.id);
warnings.push(`group member ${member.id} has no merge commit and was skipped`);
return [];
}
return [commit];
});
return {
sessionId: opts.branchGroup.id,
baselineCommit: opts.mergeBaseCommit,
integrationBranch: opts.integrationBranch,
mergeBaseCommit: opts.mergeBaseCommit,
groups: [{
id: opts.branchGroup.id,
title: opts.branchGroup.sourceId,
runRecordIds: sortedMembers.map((member) => member.id),
commits,
suggestedBranchName: opts.branchGroup.branchName,
}],
orphanedRunRecordIds,
warnings,
};
}
export function mergePlanWithUserOverrides(defaultPlan: FinalizePlan, override?: FinalizePlanOverride): FinalizePlan {
if (!override) return defaultPlan;

View File

@@ -0,0 +1,51 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { BranchGroup, MergeTargetResolution, Task, TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget } from "@fusion/core";
const execAsync = promisify(exec);
export interface BranchGroupMergeRouting {
branchGroup: BranchGroup;
mergeTarget: MergeTargetResolution;
}
async function ensureGroupBranchExists(rootDir: string, branchName: string, startPoint: string): Promise<void> {
const quotedBranch = JSON.stringify(`refs/heads/${branchName}`);
try {
await execAsync(`git show-ref --verify --quiet ${quotedBranch}`, { cwd: rootDir });
return;
} catch {
await execAsync(`git branch ${JSON.stringify(branchName)} ${JSON.stringify(startPoint)}`, { cwd: rootDir });
}
}
export async function resolveBranchGroupMergeRouting(input: {
task: Pick<Task, "branchContext" | "baseBranch">;
store: Pick<TaskStore, "getBranchGroup">;
projectDefaultBranch: string;
rootDir?: string;
}): Promise<BranchGroupMergeRouting | null> {
if (input.task.branchContext?.assignmentMode !== "shared") {
return null;
}
const groupId = input.task.branchContext.groupId;
const branchGroup = input.store.getBranchGroup(groupId);
if (!branchGroup) {
return null;
}
if (input.rootDir) {
await ensureGroupBranchExists(input.rootDir, branchGroup.branchName, input.projectDefaultBranch);
}
return {
branchGroup,
mergeTarget: resolveTaskMergeTarget(input.task, {
projectDefaultBranch: input.projectDefaultBranch,
branchGroup,
}),
};
}

View File

@@ -56,6 +56,10 @@ export {
resolveIntegrationBranchSync,
__resetIntegrationBranchCacheForTests,
} from "./integration-branch.js";
export {
resolveBranchGroupMergeRouting,
type BranchGroupMergeRouting,
} from "./group-merge-coordinator.js";
export {
resolveMergeIntegrationRoot,
resolveIntegrationRemote,

View File

@@ -128,6 +128,7 @@ import {
} from "./merger-integration-worktree.js";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js";
import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
import { syncWorktreeToHead, type SyncWorktreeResult } from "./worktree-ref-sync.js";
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger-scope-auto-widen.js";
@@ -7425,9 +7426,47 @@ export async function aiMergeTask(
const projectRootDir = rootDir;
const settings = await store.getSettings();
const resolvedIntegrationBranch = await resolveIntegrationBranch(projectRootDir, settings);
const mergeTarget = resolveTaskMergeTarget(task, {
const groupRouting = await resolveBranchGroupMergeRouting({
task,
store,
projectDefaultBranch: resolvedIntegrationBranch,
rootDir: projectRootDir,
});
const mergeTarget = groupRouting?.mergeTarget ?? resolveTaskMergeTarget(task, {
projectDefaultBranch: resolvedIntegrationBranch,
});
const recordBranchGroupMemberLanding = async () => {
if (!groupRouting) {
return;
}
try {
await Promise.resolve((store as any).recordBranchGroupMemberLanded?.(groupRouting.branchGroup.id, {
worktreePath: task.worktree ?? null,
status: "open",
}));
} catch {
// best-effort persistence
}
};
if (groupRouting) {
try {
await (store as any).recordRunAuditEvent?.({
domain: "git",
mutationType: "merge:branch-group-routed",
target: taskId,
metadata: {
groupId: groupRouting.branchGroup.id,
branchName: groupRouting.branchGroup.branchName,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
},
});
} catch {
// best-effort audit
}
}
if (mergeTarget.rejected) {
// FN-5233/FN-5530 regression: the task's baseBranch/inheritedBaseBranch
// pointed at a sibling fusion/fn-* branch. The resolver fell through to
@@ -7453,7 +7492,7 @@ export async function aiMergeTask(
// best-effort audit; never block the merge on telemetry
}
}
const integrationBranch = resolvedIntegrationBranch;
const integrationBranch = groupRouting ? mergeTarget.branch : resolvedIntegrationBranch;
let branch = task.branch || canonicalFusionBranchName(taskId);
const mergeRunId = generateSyntheticRunId("merge", taskId);
@@ -8047,6 +8086,7 @@ export async function aiMergeTask(
mergedAt: mergeDetails.mergedAt,
mergeTargetBranch: aheadInfo.baseRef,
};
await recordBranchGroupMemberLanding();
await completeTask(store, taskId, result);
await releaseReuseHandoffEarly("success");
return result;
@@ -8124,6 +8164,7 @@ export async function aiMergeTask(
mergedAt: mergeDetails.mergedAt,
mergeTargetBranch: classification.baseRef,
};
await recordBranchGroupMemberLanding();
await completeTask(store, taskId, result);
await releaseReuseHandoffEarly("success");
return result;
@@ -8347,6 +8388,7 @@ export async function aiMergeTask(
// Audit trail: record merge completion (FN-1404)
await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: true } });
await recordBranchGroupMemberLanding();
await completeTask(store, taskId, result);
return result;
}
@@ -10308,6 +10350,7 @@ export async function aiMergeTask(
attemptsMade: result.attemptsMade,
},
});
await recordBranchGroupMemberLanding();
await completeTask(store, taskId, result);
return result;