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

@@ -115,6 +115,26 @@ describe("TaskStore branch groups", () => {
expect(slim.find((entry) => entry.id === task.id)).toBeUndefined();
});
it("lists tasks by branch group and records landed member metadata", async () => {
const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-9", branchName: "fn/grouped" });
const taskA = await store.createTask({ description: "group-a" });
const taskB = await store.createTask({ description: "group-b" });
const taskC = await store.createTask({ description: "group-c" });
await store.setTaskBranchGroup(taskA.id, group.id);
await store.setTaskBranchGroup(taskC.id, group.id);
const groupedTasks = await store.listTasksByBranchGroup(group.id);
expect(groupedTasks.map((task) => task.id)).toEqual([taskA.id, taskC.id]);
expect(groupedTasks.find((task) => task.id === taskB.id)).toBeUndefined();
const landed = store.recordBranchGroupMemberLanded(group.id, {
worktreePath: "/tmp/fusion/grouped",
status: "open",
});
expect(landed.worktreePath).toBe("/tmp/fusion/grouped");
expect(landed.status).toBe("open");
});
it("preserves autoMerge + branchContext in slim list/search/modifiedSince and archived slim", async () => {
const task = await store.createTask({ description: "slim check" });
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-2", branchName: "fn/mission" });

View File

@@ -52,6 +52,25 @@ describe("resolveTaskMergeTarget", () => {
});
});
it("routes shared branch-group members to branch group integration branch", () => {
expect(resolveTaskMergeTarget({
baseBranch: undefined,
branchContext: {
groupId: "G-1",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "develop",
},
}, {
branchGroup: {
branchName: "fusion/groups/planning-g-1",
},
})).toEqual({
branch: "fusion/groups/planning-g-1",
source: "branch-group-integration",
});
});
it("falls back to inherited branch context", () => {
expect(resolveTaskMergeTarget({
baseBranch: undefined,
@@ -82,6 +101,25 @@ describe("resolveTaskMergeTarget", () => {
});
});
it("keeps per-task-derived grouped members on inherited branch context", () => {
expect(resolveTaskMergeTarget({
baseBranch: undefined,
branchContext: {
groupId: "G-2",
source: "planning",
assignmentMode: "per-task-derived",
inheritedBaseBranch: "develop",
},
}, {
branchGroup: {
branchName: "fusion/groups/planning-g-2",
},
})).toEqual({
branch: "develop",
source: "task-branch-context",
});
});
it("uses project default branch when task has no explicit target", () => {
expect(resolveTaskMergeTarget(
{ baseBranch: undefined, branchContext: undefined },
@@ -117,6 +155,32 @@ describe("resolveTaskMergeTarget", () => {
});
});
it("rejects branch-group integration branch when it points at a sibling fusion/fn-* branch", () => {
const result = resolveTaskMergeTarget(
{
baseBranch: undefined,
branchContext: {
groupId: "G-1",
source: "planning",
assignmentMode: "shared",
},
},
{
branchGroup: {
branchName: "fusion/fn-1234",
},
projectDefaultBranch: "main",
},
);
expect(result.branch).toBe("main");
expect(result.source).toBe("project-default");
expect(result.rejected).toEqual({
branch: "fusion/fn-1234",
source: "branch-group-integration",
reason: "fusion-sibling-branch",
});
});
it("rejects inherited branch context that points at a sibling fusion/fn-* branch", () => {
const result = resolveTaskMergeTarget(
{

View File

@@ -4417,6 +4417,23 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
async listTasksByBranchGroup(groupId: string): Promise<Task[]> {
const tasks = await this.listTasks({ includeArchived: false, slim: true });
return tasks
.filter((task) => task.branchContext?.groupId === groupId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
recordBranchGroupMemberLanded(
groupId: string,
patch: { worktreePath?: string | null; status?: BranchGroup["status"] },
): BranchGroup {
return this.updateBranchGroup(groupId, {
...(patch.worktreePath !== undefined ? { worktreePath: patch.worktreePath } : {}),
...(patch.status !== undefined ? { status: patch.status } : {}),
});
}
async getTaskColumns(ids: string[]): Promise<Map<string, Column>> {
if (ids.length === 0) {
return new Map();
@@ -7913,7 +7930,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
_branch: string,
task: Task,
commitMessage: string,
mergeTarget?: { branch: string; source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main" },
mergeTarget?: {
branch: string;
source: "task-base-branch" | "task-branch-context" | "branch-group-integration" | "project-default" | "legacy-main";
},
): Promise<import("./types.js").MergeDetails> {
const mergedAt = new Date().toISOString();
let commitSha: string | undefined;

View File

@@ -1,20 +1,25 @@
import type { Settings, Task, WorkflowStepResult } from "./types.js";
import type { BranchGroup, Settings, Task, WorkflowStepResult } from "./types.js";
export interface MergeTargetResolution {
branch: string;
source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
source: "task-base-branch" | "task-branch-context" | "branch-group-integration" | "project-default" | "legacy-main";
/**
* When the resolver rejects a candidate (e.g. baseBranch points at a sibling
* `fusion/fn-*` branch), this records the rejected value and the reason. The
* merger uses this to emit an audit event so the steering bug is observable
* in the run-audit timeline rather than failing silently.
*/
rejected?: { branch: string; source: "task-base-branch" | "task-branch-context"; reason: "fusion-sibling-branch" };
rejected?: {
branch: string;
source: "task-base-branch" | "task-branch-context" | "branch-group-integration";
reason: "fusion-sibling-branch";
};
}
export interface MergeTargetResolverOptions {
projectDefaultBranch?: string;
legacyFallbackBranch?: string;
branchGroup?: Pick<BranchGroup, "branchName"> | null;
}
/**
@@ -57,6 +62,21 @@ export function resolveTaskMergeTarget(
}
}
const branchGroupBranch = task.branchContext?.assignmentMode === "shared"
? options.branchGroup?.branchName?.trim()
: undefined;
if (branchGroupBranch) {
if (isFusionSiblingBranch(branchGroupBranch)) {
rejected = rejected ?? {
branch: branchGroupBranch,
source: "branch-group-integration",
reason: "fusion-sibling-branch",
};
} else {
return { branch: branchGroupBranch, source: "branch-group-integration", rejected };
}
}
const inheritedBase = task.branchContext?.inheritedBaseBranch?.trim();
if (inheritedBase) {
if (isFusionSiblingBranch(inheritedBase)) {

View File

@@ -1483,7 +1483,7 @@ export interface MergeDetails {
noOpReason?: string;
prNumber?: number;
mergeTargetBranch?: string;
mergeTargetSource?: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
mergeTargetSource?: "task-base-branch" | "task-branch-context" | "branch-group-integration" | "project-default" | "legacy-main";
resolutionStrategy?: "ai" | "auto-resolve" | "theirs" | "ours" | "abort" | "orphan-discard-no-op";
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs" | "ours" | "abort";
attemptsMade?: 1 | 2 | 3;