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:
@@ -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");
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
51
packages/engine/src/group-merge-coordinator.ts
Normal file
51
packages/engine/src/group-merge-coordinator.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -56,6 +56,10 @@ export {
|
||||
resolveIntegrationBranchSync,
|
||||
__resetIntegrationBranchCacheForTests,
|
||||
} from "./integration-branch.js";
|
||||
export {
|
||||
resolveBranchGroupMergeRouting,
|
||||
type BranchGroupMergeRouting,
|
||||
} from "./group-merge-coordinator.js";
|
||||
export {
|
||||
resolveMergeIntegrationRoot,
|
||||
resolveIntegrationRemote,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user