FN-5788: add group promotion gate to merge coordinator

Add lifecycle wiring so branch-group promotion respects group-level auto-merge gating.

- Extend merge coordinator/merger flow to apply group promotion precedence and pause/override reasons.
- Hook the group-promotion coordinator into engine lifecycle startup.
- Add reliability and coordinator coverage for branch group promotion gating behavior.
- Add release changeset and update architecture/AGENTS backstop notes.

Files changed:
 .changeset/fn-5788-branch-group-promotion-gate.md  |   5 +
 AGENTS.md                                          |   1 +
 docs/architecture.md                               |   3 +-
 .../src/__tests__/group-merge-coordinator.test.ts  |  95 ++++++++++++++-
 .../branch-group-promotion-gate.test.ts            | 129 +++++++++++++++++++++
 packages/engine/src/group-merge-coordinator.ts     |  46 +++++---
 packages/engine/src/index.ts                       |   2 +
 packages/engine/src/merger.ts                      |  34 +++---
 8 files changed, 283 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-5788

Fusion-Task-Lineage: 5930f662-908e-4cf0-a0d4-68b2f4360074
This commit is contained in:
gsxdsm
2026-05-31 15:59:59 -07:00
parent 06d84905ba
commit 7b70e7fb72
8 changed files with 287 additions and 36 deletions

View File

@@ -5,7 +5,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it, afterEach } from "vitest";
import { resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
import { evaluateBranchGroupPromotion, resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
const dirs: string[] = [];
@@ -24,6 +24,99 @@ afterEach(async () => {
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
describe("evaluateBranchGroupPromotion", () => {
const baseGroup = {
id: "BG-1",
branchName: "fusion/groups/planning-x",
autoMerge: true,
status: "open" as const,
};
const baseSettings: {
autoMerge: boolean;
globalPause: boolean;
enginePaused: boolean;
} = {
autoMerge: true,
globalPause: false,
enginePaused: false,
};
it("returns eligible when pauses are off and automerge resolves true", () => {
const decision = evaluateBranchGroupPromotion({
group: baseGroup,
settings: baseSettings,
});
expect(decision).toEqual({
eligible: true,
reason: "eligible",
groupAutoMerge: true,
});
});
it("returns group-automerge-disabled when group autoMerge is false", () => {
const decision = evaluateBranchGroupPromotion({
group: { ...baseGroup, autoMerge: false },
settings: baseSettings,
});
expect(decision).toEqual({
eligible: false,
reason: "group-automerge-disabled",
groupAutoMerge: false,
});
});
it("returns settings-automerge-disabled when settings autoMerge is false", () => {
const withDefaultedGroup = evaluateBranchGroupPromotion({
group: { ...baseGroup, autoMerge: undefined as unknown as boolean },
settings: { ...baseSettings, autoMerge: false },
});
expect(withDefaultedGroup).toEqual({
eligible: false,
reason: "settings-automerge-disabled",
groupAutoMerge: false,
});
const withExplicitGroupTrue = evaluateBranchGroupPromotion({
group: { ...baseGroup, autoMerge: true },
settings: { ...baseSettings, autoMerge: false },
});
expect(withExplicitGroupTrue).toEqual({
eligible: false,
reason: "settings-automerge-disabled",
groupAutoMerge: true,
});
});
it("returns global-pause before other gates", () => {
const decision = evaluateBranchGroupPromotion({
group: { ...baseGroup, autoMerge: true },
settings: { ...baseSettings, globalPause: true, autoMerge: false },
});
expect(decision).toEqual({
eligible: false,
reason: "global-pause",
groupAutoMerge: true,
});
});
it("returns engine-paused before automerge gate when global pause is off", () => {
const decision = evaluateBranchGroupPromotion({
group: { ...baseGroup, autoMerge: true },
settings: { ...baseSettings, enginePaused: true, autoMerge: false },
});
expect(decision).toEqual({
eligible: false,
reason: "engine-paused",
groupAutoMerge: true,
});
});
});
describe("resolveBranchGroupMergeRouting", () => {
it("returns null for non-shared tasks", async () => {
const routing = await resolveBranchGroupMergeRouting({

View File

@@ -0,0 +1,129 @@
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 worktreePath = join(`${rootDir}-worktrees`, 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);
}
type Scenario = {
name: string;
groupAutoMerge?: boolean;
settings: Record<string, unknown>;
expected: { effectiveEligible: boolean; reason: string; groupAutoMerge: boolean };
};
function findPromotionGateEvent(store: TaskStore, groupId: string) {
const events = store.getRunAuditEvents();
return events.find((event) => event.mutationType === "merge:branch-group-promotion-gated" && (event.metadata as any)?.groupId === groupId);
}
describe("FN-5788 reliability interactions: branch group promotion gate", () => {
const scenarios: Scenario[] = [
{
name: "eligible gate emits without promoting default branch",
groupAutoMerge: true,
settings: { autoMerge: true },
expected: { effectiveEligible: true, reason: "eligible", groupAutoMerge: true },
},
{
name: "group-automerge-disabled when group autoMerge is false",
groupAutoMerge: false,
settings: { autoMerge: true },
expected: { effectiveEligible: false, reason: "group-automerge-disabled", groupAutoMerge: false },
},
{
name: "global-pause override",
groupAutoMerge: true,
settings: { autoMerge: true, globalPause: true },
expected: { effectiveEligible: false, reason: "global-pause", groupAutoMerge: true },
},
{
name: "engine-paused override",
groupAutoMerge: true,
settings: { autoMerge: true, enginePaused: true },
expected: { effectiveEligible: false, reason: "engine-paused", groupAutoMerge: true },
},
{
name: "settings-automerge-disabled override",
groupAutoMerge: true,
settings: { autoMerge: false, globalPause: false, enginePaused: false },
expected: { effectiveEligible: false, reason: "settings-automerge-disabled", groupAutoMerge: true },
},
];
for (const [index, scenario] of scenarios.entries()) {
it.skipIf(!hasGit)(scenario.name, async () => {
const fixture = await makeReliabilityFixture({ settings: { ...scenario.settings, testMode: true } as any });
try {
const { rootDir, store, task } = fixture;
const fileName = `fn5788Gate${index}`;
await stageMergeBranch(store, rootDir, task.id, fileName);
const group = store.createBranchGroup({
sourceType: "planning",
sourceId: `PS-FN5788-${index}`,
branchName: `fusion/groups/fn-5788-gate-${index}`,
autoMerge: scenario.groupAutoMerge,
});
await store.setTaskBranchGroup(task.id, group.id);
await store.updateTask(task.id, {
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
} as any);
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/${fileName}.ts`)).toContain(fileName);
expect(() => git(rootDir, `git show main:packages/engine/src/${fileName}.ts`)).toThrow();
expect(store.getBranchGroup(group.id)?.status).toBe("open");
expect(findPromotionGateEvent(store, group.id)?.metadata).toEqual(expect.objectContaining({
groupId: group.id,
branchName: group.branchName,
groupAutoMerge: scenario.expected.groupAutoMerge,
effectiveEligible: scenario.expected.effectiveEligible,
reason: scenario.expected.reason,
}));
expect(auditSpy).toHaveBeenCalledWith(expect.objectContaining({
domain: "git",
mutationType: "merge:branch-group-promotion-gated",
target: task.id,
metadata: expect.objectContaining({
groupId: group.id,
branchName: group.branchName,
groupAutoMerge: scenario.expected.groupAutoMerge,
effectiveEligible: scenario.expected.effectiveEligible,
reason: scenario.expected.reason,
}),
}));
} finally {
await fixture.cleanup();
}
}, 45_000);
}
});

View File

@@ -11,28 +11,40 @@ export interface BranchGroupMergeRouting {
mergeTarget: MergeTargetResolution;
}
export type BranchGroupPromotionEligibilityReason =
| "group-automerge-disabled"
| "global-pause"
| "engine-paused"
| "settings-automerge-disabled"
| "eligible";
export interface BranchGroupPromotionDecision {
eligible: boolean;
groupAutoMerge: boolean;
reason:
| "group-automerge-disabled"
| "settings-automerge-disabled"
| "global-pause"
| "engine-paused"
| "eligible";
}
export function isGroupPromotionAutoMergeEligible(
group: Pick<BranchGroup, "autoMerge">,
settings: Pick<Settings, "autoMerge" | "globalPause" | "enginePaused">,
): { eligible: boolean; reason: BranchGroupPromotionEligibilityReason; groupAutoMerge: boolean } {
const groupAutoMerge = resolveEffectiveGroupAutoMerge(group, settings);
if (!groupAutoMerge) {
return { eligible: false, reason: "group-automerge-disabled", groupAutoMerge };
}
if (settings.globalPause) {
/**
* Evaluates branch-group → default-branch PROMOTION eligibility only.
* This does not perform promotion and does not govern member → group-integration
* routing, which stays on the FN-5782 task-level auto-merge path.
*/
export function evaluateBranchGroupPromotion(input: {
group: Pick<BranchGroup, "id" | "branchName" | "autoMerge" | "status">;
settings: Pick<Settings, "autoMerge" | "globalPause" | "enginePaused">;
}): BranchGroupPromotionDecision {
const groupAutoMerge = resolveEffectiveGroupAutoMerge(input.group, input.settings);
if (input.settings.globalPause) {
return { eligible: false, reason: "global-pause", groupAutoMerge };
}
if (settings.enginePaused) {
if (input.settings.enginePaused) {
return { eligible: false, reason: "engine-paused", groupAutoMerge };
}
if (!settings.autoMerge) {
if (!groupAutoMerge) {
if (input.group.autoMerge === false) {
return { eligible: false, reason: "group-automerge-disabled", groupAutoMerge };
}
return { eligible: false, reason: "settings-automerge-disabled", groupAutoMerge };
}
if (!input.settings.autoMerge) {
return { eligible: false, reason: "settings-automerge-disabled", groupAutoMerge };
}
return { eligible: true, reason: "eligible", groupAutoMerge };

View File

@@ -58,7 +58,9 @@ export {
} from "./integration-branch.js";
export {
resolveBranchGroupMergeRouting,
evaluateBranchGroupPromotion,
type BranchGroupMergeRouting,
type BranchGroupPromotionDecision,
} from "./group-merge-coordinator.js";
export {
resolveMergeIntegrationRoot,

View File

@@ -128,7 +128,7 @@ import {
} from "./merger-integration-worktree.js";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { isGroupPromotionAutoMergeEligible, resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js";
import { evaluateBranchGroupPromotion, 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";
@@ -7444,9 +7444,32 @@ export async function aiMergeTask(
} catch {
// best-effort persistence
}
const promotionEligibility = evaluateBranchGroupPromotion({
group: groupRouting.branchGroup,
settings,
});
try {
await (store as any).recordRunAuditEvent?.({
taskId,
agentId: "merger",
runId: `merge-${taskId}`,
domain: "git",
mutationType: "merge:branch-group-promotion-gated",
target: taskId,
metadata: {
groupId: groupRouting.branchGroup.id,
branchName: groupRouting.branchGroup.branchName,
groupAutoMerge: promotionEligibility.groupAutoMerge,
effectiveEligible: promotionEligibility.eligible,
reason: promotionEligibility.reason,
},
});
} catch {
// best-effort audit
}
};
if (groupRouting) {
const promotionEligibility = isGroupPromotionAutoMergeEligible(groupRouting.branchGroup, settings);
const auditRunId = `merge-${taskId}`;
try {
await (store as any).recordRunAuditEvent?.({
@@ -7463,21 +7486,6 @@ export async function aiMergeTask(
mergeTargetSource: mergeTarget.source,
},
});
await (store as any).recordRunAuditEvent?.({
taskId,
agentId: "merger",
runId: auditRunId,
domain: "git",
mutationType: "merge:branch-group-promotion-gated",
target: taskId,
metadata: {
groupId: groupRouting.branchGroup.id,
branchName: groupRouting.branchGroup.branchName,
groupAutoMerge: promotionEligibility.groupAutoMerge,
effectiveEligible: promotionEligibility.eligible,
reason: promotionEligibility.reason,
},
});
} catch {
// best-effort audit
}