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:
@@ -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" });
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user