FN-5830: re-land shared branch promotion gate and API

Reintroduce shared-branch-group completion gating and promotion endpoints with reliability coverage.

- add GroupMergeCoordinator promotion gate behavior and shared branch group promotion logic updates
- expose promotion flow wiring through engine index and project engine APIs
- expand coordinator tests and add reliability interaction coverage for branch-group promotion
- document the new reliability backstop and add a changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5830-branch-group-promotion.md       |   5 +
 AGENTS.md                                          |   1 +
 docs/architecture.md                               |   3 +-
 .../src/__tests__/group-merge-coordinator.test.ts  | 170 ++++++++++++++++++-
 .../branch-group-promotion.test.ts                 | 166 +++++++++++++++++++
 packages/engine/src/group-merge-coordinator.ts     | 183 ++++++++++++++++++++-
 packages/engine/src/index.ts                       |   4 +
 packages/engine/src/project-engine.ts              |  37 +++++
 8 files changed, 566 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5830

Fusion-Task-Lineage: e9823f51-df0e-4da8-8f51-58dfefdc293d
This commit is contained in:
gsxdsm
2026-06-01 05:39:01 -07:00
parent eb425d17d9
commit 3373c0beed
8 changed files with 566 additions and 3 deletions

View File

@@ -5,7 +5,12 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it, afterEach } from "vitest";
import { evaluateBranchGroupPromotion, resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
import {
evaluateBranchGroupCompletion,
evaluateBranchGroupPromotion,
promoteBranchGroup,
resolveBranchGroupMergeRouting,
} from "../group-merge-coordinator.js";
const dirs: string[] = [];
@@ -24,6 +29,59 @@ afterEach(async () => {
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
describe("evaluateBranchGroupCompletion", () => {
it("returns complete when all members are landed", () => {
const result = evaluateBranchGroupCompletion({
members: [
{ id: "FN-A", column: "done" as const },
{ id: "FN-B", column: "in-review" as const, mergeDetails: { mergeTargetSource: "branch-group-integration" } as any },
] as any,
});
expect(result).toEqual({
complete: true,
totalMembers: 2,
landedMemberIds: ["FN-A", "FN-B"],
pendingMemberIds: [],
});
});
it("returns pending ids when one member is not landed", () => {
const result = evaluateBranchGroupCompletion({
members: [
{ id: "FN-A", column: "done" as const },
{ id: "FN-B", column: "todo" as const },
] as any,
});
expect(result.complete).toBe(false);
expect(result.landedMemberIds).toEqual(["FN-A"]);
expect(result.pendingMemberIds).toEqual(["FN-B"]);
});
it("treats empty groups as incomplete", () => {
const result = evaluateBranchGroupCompletion({ members: [] });
expect(result).toEqual({
complete: false,
totalMembers: 0,
landedMemberIds: [],
pendingMemberIds: [],
});
});
it("counts mixed done + landed in-review members as complete", () => {
const result = evaluateBranchGroupCompletion({
members: [
{ id: "FN-A", column: "done" as const },
{ id: "FN-B", column: "in-review" as const, mergeDetails: { mergeTargetSource: "branch-group-integration" } as any },
] as any,
});
expect(result.complete).toBe(true);
expect(result.pendingMemberIds).toEqual([]);
});
});
describe("evaluateBranchGroupPromotion", () => {
const baseGroup = {
id: "BG-1",
@@ -117,6 +175,116 @@ describe("evaluateBranchGroupPromotion", () => {
});
});
describe("promoteBranchGroup", () => {
function makeGroup(overrides?: Partial<any>) {
return {
id: "BG-1",
sourceType: "planning",
sourceId: "planning:x",
branchName: "fusion/groups/planning-x",
autoMerge: true,
prState: "none",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
};
}
it("returns incomplete without merging when members are pending", async () => {
const rootDir = makeRepo();
const group = makeGroup();
const result = await promoteBranchGroup({
rootDir,
groupId: group.id,
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
store: {
getBranchGroup: () => group,
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "todo" }],
updateBranchGroup: () => {
throw new Error("should not update");
},
} as any,
});
expect(result.reason).toBe("incomplete");
expect(() => execSync("git show main:group.txt", { cwd: rootDir })).toThrow();
});
it("returns gated and emits audit when promotion gates are disabled", async () => {
const rootDir = makeRepo();
const group = makeGroup({ autoMerge: false });
const audits: Array<Record<string, unknown>> = [];
const result = await promoteBranchGroup({
rootDir,
groupId: group.id,
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
recordAudit: async (event) => { audits.push(event as Record<string, unknown>); },
store: {
getBranchGroup: () => group,
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }],
updateBranchGroup: () => {
throw new Error("should not update");
},
} as any,
});
expect(result.reason).toBe("gated");
expect(audits).toEqual(expect.arrayContaining([
expect.objectContaining({ mutationType: "merge:branch-group-promotion-gated" }),
]));
});
it("merges group branch once and finalizes group when complete and eligible", async () => {
const rootDir = makeRepo();
execSync("git checkout -b fusion/groups/planning-x", { cwd: rootDir });
execSync("echo promoted > group.txt", { cwd: rootDir, shell: "/bin/bash" });
execSync("git add group.txt && git commit -m group", { cwd: rootDir, shell: "/bin/bash" });
execSync("git checkout main", { cwd: rootDir });
let group = makeGroup();
const audits: Array<Record<string, unknown>> = [];
const first = await promoteBranchGroup({
rootDir,
groupId: group.id,
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
recordAudit: async (event) => { audits.push(event as Record<string, unknown>); },
store: {
getBranchGroup: () => group,
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }],
updateBranchGroup: (_id: string, patch: Partial<typeof group>) => {
group = { ...group, ...patch };
return group;
},
} as any,
});
expect(first.promoted).toBe(true);
expect(first.reason).toBe("promoted");
expect(group.status).toBe("finalized");
expect(group.prState).toBe("merged");
expect(execSync("git show main:group.txt", { cwd: rootDir, encoding: "utf8" })).toContain("promoted");
const second = await promoteBranchGroup({
rootDir,
groupId: group.id,
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
recordAudit: async (event) => { audits.push(event as Record<string, unknown>); },
store: {
getBranchGroup: () => group,
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }],
updateBranchGroup: (_id: string, patch: Partial<typeof group>) => {
group = { ...group, ...patch };
return group;
},
} as any,
});
expect(second.reason).toBe("already-finalized");
expect(audits.filter((event) => event.mutationType === "merge:branch-group-promoted")).toHaveLength(1);
});
});
describe("resolveBranchGroupMergeRouting", () => {
it("returns null for non-shared tasks", async () => {
const routing = await resolveBranchGroupMergeRouting({

View File

@@ -0,0 +1,166 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { type TaskStore } from "@fusion/core";
import { aiMergeTask } from "../../merger.js";
import { promoteBranchGroup } from "../../group-merge-coordinator.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);
}
describe("FN-5830 reliability interactions: branch group promotion", () => {
it.skipIf(!hasGit)("promotes exactly once after all members land", async () => {
const fixture = await makeReliabilityFixture({ taskId: "FN-5830-RI-A", settings: { testMode: true, autoMerge: true } as any });
try {
const { rootDir, store, task } = fixture;
const second = await store.createTask({
id: "FN-5830-RI-B",
title: "FN-5830-RI-B",
description: "second member",
column: "in-review",
baseBranch: "main",
branch: "fusion/fn-5830-ri-b",
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
steps: [],
} as any);
await stageMergeBranch(store, rootDir, task.id, "fn5830MemberA");
await stageMergeBranch(store, rootDir, second.id, "fn5830MemberB");
const group = store.createBranchGroup({
sourceType: "planning",
sourceId: "PS-FN5830-A",
branchName: "fusion/groups/fn-5830-a",
autoMerge: true,
});
await store.setTaskBranchGroup(task.id, group.id);
await store.setTaskBranchGroup(second.id, group.id);
await store.updateTask(task.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any);
await store.updateTask(second.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any);
const firstMerge = await aiMergeTask(store, rootDir, task.id);
expect(firstMerge.merged).toBe(true);
await store.updateTask(task.id, {
column: "done",
mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
} as any);
expect(() => git(rootDir, "git show main:packages/engine/src/fn5830MemberA.ts")).toThrow();
const audits: Array<any> = [];
const promoteWithMembers = async (memberIds: string[]) => promoteBranchGroup({
store: {
getBranchGroup: (...args: any[]) => (store as any).getBranchGroup(...args),
updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args),
listTasksByBranchGroup: async () => Promise.all(memberIds.map(async (id) => await store.getTask(id))).then((tasks) => tasks.filter(Boolean) as any),
} as any,
rootDir,
groupId: group.id,
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" } as any,
recordAudit: (e) => { audits.push(e); },
});
const incomplete = await promoteWithMembers([task.id, second.id]);
expect(incomplete.reason).toBe("incomplete");
const secondMerge = await aiMergeTask(store, rootDir, second.id);
expect(secondMerge.merged).toBe(true);
await store.updateTask(second.id, {
column: "done",
mergeDetails: { ...(await store.getTask(second.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
} as any);
const promoted = await promoteWithMembers([task.id, second.id]);
expect(promoted.reason).toBe("promoted");
expect(git(rootDir, "git show main:packages/engine/src/fn5830MemberA.ts")).toContain("fn5830MemberA");
expect(git(rootDir, "git show main:packages/engine/src/fn5830MemberB.ts")).toContain("fn5830MemberB");
expect(store.getBranchGroup(group.id)?.status).toBe("finalized");
expect(store.getBranchGroup(group.id)?.prState).toBe("merged");
const again = await promoteWithMembers([task.id, second.id]);
expect(again.reason).toBe("already-finalized");
const promoteEvents = audits.filter((event) => event.mutationType === "merge:branch-group-promoted" && (event.metadata as any)?.groupId === group.id);
expect(promoteEvents).toHaveLength(1);
} finally {
await fixture.cleanup();
}
}, 45_000);
it.skipIf(!hasGit)("respects disabled gate and does not promote", async () => {
const scenarios: Array<{ name: string; settings: any; groupAutoMerge?: boolean; fileName: string }> = [
{
name: "settings auto-merge disabled",
settings: { testMode: true, autoMerge: false },
groupAutoMerge: true,
fileName: "fn5830GateSettings",
},
{
name: "group auto-merge disabled",
settings: { testMode: true, autoMerge: true },
groupAutoMerge: false,
fileName: "fn5830GateGroup",
},
];
for (const scenario of scenarios) {
const fixture = await makeReliabilityFixture({ taskId: `FN-5830-RI-GATE-${scenario.fileName}`, settings: scenario.settings });
try {
const { rootDir, store, task } = fixture;
await stageMergeBranch(store, rootDir, task.id, scenario.fileName);
const group = store.createBranchGroup({
sourceType: "planning",
sourceId: `PS-FN5830-GATE-${scenario.fileName}`,
branchName: `fusion/groups/fn-5830-gate-${scenario.fileName}`,
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 mergeResult = await aiMergeTask(store, rootDir, task.id);
expect(mergeResult.merged).toBe(true);
await store.updateTask(task.id, {
column: "done",
mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
} as any);
const audits: Array<any> = [];
const gated = await promoteBranchGroup({
store: {
getBranchGroup: (...args: any[]) => (store as any).getBranchGroup(...args),
updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args),
listTasksByBranchGroup: async () => [await store.getTask(task.id)].filter(Boolean) as any,
} as any,
rootDir,
groupId: group.id,
settings: await store.getSettings() as any,
recordAudit: (e) => { audits.push(e); },
});
expect(gated.reason, scenario.name).toBe("gated");
expect(() => git(rootDir, `git show main:packages/engine/src/${scenario.fileName}.ts`), scenario.name).toThrow();
expect(store.getBranchGroup(group.id)?.status, scenario.name).toBe("open");
expect(store.getBranchGroup(group.id)?.prState, scenario.name).toBe("none");
expect(audits.find((event) => event.mutationType === "merge:branch-group-promotion-gated" && (event.metadata as any)?.groupId === group.id), scenario.name).toBeTruthy();
} finally {
await fixture.cleanup();
}
}
}, 45_000);
});

View File

@@ -1,8 +1,9 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { BranchGroup, MergeTargetResolution, Settings, Task, TaskStore } from "@fusion/core";
import type { BranchGroup, BranchGroupPrState, MergeTargetResolution, Settings, Task, TaskStore } from "@fusion/core";
import { resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget } from "@fusion/core";
import { resolveIntegrationBranch } from "./integration-branch.js";
const execAsync = promisify(exec);
@@ -11,6 +12,24 @@ export interface BranchGroupMergeRouting {
mergeTarget: MergeTargetResolution;
}
export interface BranchGroupCompletionStatus {
complete: boolean;
totalMembers: number;
landedMemberIds: string[];
pendingMemberIds: string[];
}
export interface BranchGroupPromotionResult {
groupId: string;
promoted: boolean;
alreadyFinalized: boolean;
reason: "promoted" | "incomplete" | "gated" | "already-finalized" | "group-not-found";
status: BranchGroup["status"];
prState: BranchGroupPrState;
prNumber?: number;
prUrl?: string;
}
export interface BranchGroupPromotionDecision {
eligible: boolean;
groupAutoMerge: boolean;
@@ -22,6 +41,31 @@ export interface BranchGroupPromotionDecision {
| "eligible";
}
export function evaluateBranchGroupCompletion(input: {
members: Pick<Task, "id" | "column" | "branchContext" | "mergeDetails">[];
}): BranchGroupCompletionStatus {
const landedMemberIds: string[] = [];
const pendingMemberIds: string[] = [];
for (const member of input.members) {
const landed = member.column === "done"
|| (member.column === "in-review" && member.mergeDetails?.mergeTargetSource === "branch-group-integration");
if (landed) {
landedMemberIds.push(member.id);
} else {
pendingMemberIds.push(member.id);
}
}
const totalMembers = input.members.length;
return {
complete: totalMembers > 0 && pendingMemberIds.length === 0,
totalMembers,
landedMemberIds,
pendingMemberIds,
};
}
/**
* Evaluates branch-group → default-branch PROMOTION eligibility only.
* This does not perform promotion and does not govern member → group-integration
@@ -60,6 +104,143 @@ async function ensureGroupBranchExists(rootDir: string, branchName: string, star
}
}
/**
* The only entrypoint allowed to perform shared-branch-group → default-branch promotion.
* Promotion is intentionally idempotent and must never run inline in aiMergeTask.
*/
export async function promoteBranchGroup(input: {
store: Pick<TaskStore, "getBranchGroup" | "listTasksByBranchGroup" | "updateBranchGroup">;
rootDir: string;
groupId: string;
settings: Pick<Settings, "autoMerge" | "globalPause" | "enginePaused"> & Partial<Pick<Settings, "mergeStrategy" | "integrationBranch" | "baseBranch">>;
recordAudit?: (event: {
domain: string;
mutationType: string;
target: string;
metadata?: Record<string, unknown>;
}) => Promise<void> | void;
}): Promise<BranchGroupPromotionResult> {
const group = input.store.getBranchGroup(input.groupId);
if (!group) {
return {
groupId: input.groupId,
promoted: false,
alreadyFinalized: false,
reason: "group-not-found",
status: "abandoned",
prState: "none",
};
}
if (group.status === "finalized" || group.prState === "merged") {
return {
groupId: group.id,
promoted: false,
alreadyFinalized: true,
reason: "already-finalized",
status: group.status,
prState: group.prState,
prNumber: group.prNumber,
prUrl: group.prUrl,
};
}
if (group.prState === "open") {
return {
groupId: group.id,
promoted: false,
alreadyFinalized: true,
reason: "already-finalized",
status: group.status,
prState: group.prState,
prNumber: group.prNumber,
prUrl: group.prUrl,
};
}
const members = await input.store.listTasksByBranchGroup(group.id);
const completion = evaluateBranchGroupCompletion({ members });
if (!completion.complete) {
return {
groupId: group.id,
promoted: false,
alreadyFinalized: false,
reason: "incomplete",
status: group.status,
prState: group.prState,
prNumber: group.prNumber,
prUrl: group.prUrl,
};
}
const eligibility = evaluateBranchGroupPromotion({ group, settings: input.settings });
if (!eligibility.eligible) {
await input.recordAudit?.({
domain: "git",
mutationType: "merge:branch-group-promotion-gated",
target: group.id,
metadata: {
groupId: group.id,
branchName: group.branchName,
groupAutoMerge: eligibility.groupAutoMerge,
effectiveEligible: false,
reason: eligibility.reason,
},
});
return {
groupId: group.id,
promoted: false,
alreadyFinalized: false,
reason: "gated",
status: group.status,
prState: group.prState,
prNumber: group.prNumber,
prUrl: group.prUrl,
};
}
const integrationBranch = await resolveIntegrationBranch(input.rootDir, input.settings);
await ensureGroupBranchExists(input.rootDir, group.branchName, integrationBranch);
const currentBranch = (await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: input.rootDir })).stdout.trim();
try {
await execAsync(`git checkout ${JSON.stringify(integrationBranch)}`, { cwd: input.rootDir });
await execAsync(`git merge --no-ff --no-edit ${JSON.stringify(group.branchName)}`, { cwd: input.rootDir });
} finally {
await execAsync(`git checkout ${JSON.stringify(currentBranch)}`, { cwd: input.rootDir });
}
const isPrMode = input.settings.mergeStrategy === "pull-request";
const updatedGroup = input.store.updateBranchGroup(group.id, {
status: "finalized",
prState: isPrMode ? "open" : "merged",
});
await input.recordAudit?.({
domain: "git",
mutationType: "merge:branch-group-promoted",
target: group.id,
metadata: {
groupId: group.id,
branchName: group.branchName,
integrationBranch,
memberIds: completion.landedMemberIds,
...(updatedGroup.prNumber ? { prNumber: updatedGroup.prNumber } : {}),
...(updatedGroup.prUrl ? { prUrl: updatedGroup.prUrl } : {}),
},
});
return {
groupId: group.id,
promoted: true,
alreadyFinalized: false,
reason: "promoted",
status: updatedGroup.status,
prState: updatedGroup.prState,
prNumber: updatedGroup.prNumber,
prUrl: updatedGroup.prUrl,
};
}
export async function resolveBranchGroupMergeRouting(input: {
task: Pick<Task, "branchContext" | "baseBranch">;
store: Pick<TaskStore, "getBranchGroup">;

View File

@@ -59,8 +59,12 @@ export {
export {
resolveBranchGroupMergeRouting,
evaluateBranchGroupPromotion,
evaluateBranchGroupCompletion,
promoteBranchGroup,
type BranchGroupMergeRouting,
type BranchGroupPromotionDecision,
type BranchGroupCompletionStatus,
type BranchGroupPromotionResult,
} from "./group-merge-coordinator.js";
export {
resolveMergeIntegrationRoot,

View File

@@ -27,6 +27,7 @@ import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
import type { RoutineRunner } from "./routine-runner.js";
import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js";
import { runAiMerge } from "./merger-ai.js";
import { promoteBranchGroup } from "./group-merge-coordinator.js";
import { PRIORITY_MERGE } from "./concurrency.js";
import { runtimeLog } from "./logger.js";
import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
@@ -1782,6 +1783,39 @@ export class ProjectEngine {
}
const mergeStrategy = this.options.getMergeStrategy?.(settings) ?? "direct";
const promotionSettings = {
autoMerge: settings.autoMerge,
globalPause: settings.globalPause,
enginePaused: settings.enginePaused,
mergeStrategy: settings.mergeStrategy,
integrationBranch: settings.integrationBranch,
baseBranch: settings.baseBranch,
};
const attemptBranchGroupPromotion = async (taskForPromotion: Task | null): Promise<void> => {
if (!taskForPromotion || !isSharedBranchGroupMemberIntegration(taskForPromotion)) {
return;
}
try {
await promoteBranchGroup({
store,
rootDir: cwd,
groupId: taskForPromotion.branchContext!.groupId,
settings: promotionSettings,
recordAudit: async (event) => {
await store.recordRunAuditEvent({
domain: event.domain as any,
mutationType: event.mutationType,
target: event.target,
metadata: event.metadata,
} as any);
},
});
} catch (promotionError) {
runtimeLog.warn(
`Branch-group promotion evaluation failed for ${taskId}: ${promotionError instanceof Error ? promotionError.message : String(promotionError)}`,
);
}
};
if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) {
this.activeMergeTaskId = taskId;
@@ -1807,6 +1841,7 @@ export class ProjectEngine {
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
} as MergeResult);
}
await attemptBranchGroupPromotion(mergedTask);
} else if (result === "waiting") {
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge PR waiting: ${taskId}`);
}
@@ -1882,6 +1917,8 @@ export class ProjectEngine {
if (latestTask?.mergeRetries && latestTask.mergeRetries > 0) {
await store.updateTask(taskId, { mergeRetries: 0 });
}
await attemptBranchGroupPromotion(latestTask);
}
} catch (err: unknown) {
this.activeMergeSession = null;