FN-5783: enforce branch-group autoMerge precedence for shared promotions

Honor group-level autoMerge precedence when promoting shared branch groups.

- add core/store helpers to preserve branch-group autoMerge overrides and resolve effective precedence against task/project settings
- gate branch-group promotion eligibility in engine merge coordination and emit promotion-gated audit metadata
- update dashboard planning/subtask flows for shared mission triage routing and add reliability/unit coverage
- include docs and changeset updates for branch-group autoMerge precedence behavior

Files changed:
 .../fn-5783-branch-group-automerge-precedence.md   |   5 +
 AGENTS.md                                          |   1 +
 docs/settings-reference.md                         |   2 +-
 docs/workflow-steps.md                             |   2 +
 .../core/src/__tests__/branch-group-store.test.ts  |  18 ++++
 packages/core/src/__tests__/task-merge.test.ts     |  18 ++++
 packages/core/src/index.ts                         |   1 +
 packages/core/src/mission-store.ts                 |  13 +++
 packages/core/src/store.ts                         |  17 ++++
 packages/core/src/task-merge.ts                    |   8 ++
 .../dashboard/src/__tests__/mission-e2e.test.ts    |  98 ++++++++++++++++--
 .../src/__tests__/routes-planning.test.ts          | 109 ++++++++++++++++++++-
 packages/dashboard/src/planning.ts                 |   1 +
 .../src/routes/register-planning-subtask-routes.ts |  28 ++++++
 packages/dashboard/src/subtask-breakdown.ts        |   1 +
 .../branch-group-automerge-precedence.test.ts      | 102 +++++++++++++++++++
 packages/engine/src/group-merge-coordinator.ts     |  31 +++++-
 packages/engine/src/merger.ts                      |  30 ++++--
 18 files changed, 466 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-5783

Fusion-Task-Lineage: 74327984-b14f-445c-81cd-5295ee562382
This commit is contained in:
gsxdsm
2026-05-31 15:17:11 -07:00
parent c8ae94db6b
commit 06d84905ba
18 changed files with 466 additions and 19 deletions

View File

@@ -0,0 +1,102 @@
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 { 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);
}
function listAuditEvents(store: TaskStore) {
const persisted = store.getRunAuditEvents();
const transient = Array.isArray((store as any).__audits) ? (store as any).__audits : [];
return [...transient, ...persisted] as Array<{ mutationType?: string; metadata?: Record<string, unknown> }>;
}
function findGateEvent(store: TaskStore, groupId: string) {
return listAuditEvents(store).find((event) =>
event.mutationType === "merge:branch-group-promotion-gated" && (event.metadata as any)?.groupId === groupId,
);
}
describe("FN-5783 reliability interactions: branch group automerge precedence", () => {
it.skipIf(!hasGit)("records eligible when group autoMerge=true even if task autoMerge=false", async () => {
const fixture = await makeReliabilityFixture({ settings: { autoMerge: true, testMode: true } as any });
try {
const { rootDir, store, task } = fixture;
await stageMergeBranch(store, rootDir, task.id, "fn5783Eligible");
const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-5783", branchName: "fusion/groups/fn-5783", autoMerge: true });
await store.setTaskBranchGroup(task.id, group.id);
await store.updateTask(task.id, {
autoMerge: false,
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
} as any);
await aiMergeTask(store, rootDir, task.id);
expect(findGateEvent(store, group.id)?.metadata).toMatchObject({ groupId: group.id, groupAutoMerge: true, effectiveEligible: true, reason: "eligible" });
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("records disabled when group autoMerge=false even if task autoMerge=true", async () => {
const fixture = await makeReliabilityFixture({ settings: { autoMerge: true, testMode: true } as any });
try {
const { rootDir, store, task } = fixture;
await stageMergeBranch(store, rootDir, task.id, "fn5783Disabled");
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-5783", branchName: "fusion/groups/fn-5783-disabled", autoMerge: false });
await store.setTaskBranchGroup(task.id, group.id);
await store.updateTask(task.id, {
autoMerge: true,
branchContext: { groupId: group.id, source: "mission", assignmentMode: "shared" },
} as any);
await aiMergeTask(store, rootDir, task.id);
expect(findGateEvent(store, group.id)?.metadata).toMatchObject({ groupId: group.id, groupAutoMerge: false, effectiveEligible: false, reason: "group-automerge-disabled" });
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("applies pause and settings overrides", async () => {
const scenarios = [
{ settings: { autoMerge: true, globalPause: true }, reason: "global-pause" },
{ settings: { autoMerge: true, enginePaused: true }, reason: "engine-paused" },
{ settings: { autoMerge: false, globalPause: false, enginePaused: false }, reason: "settings-automerge-disabled" },
] as const;
for (const [index, scenario] of scenarios.entries()) {
const fixture = await makeReliabilityFixture({ settings: { ...scenario.settings, testMode: true } as any });
try {
const { rootDir, store, task } = fixture;
await stageMergeBranch(store, rootDir, task.id, `fn5783Gate${index}`);
const group = store.createBranchGroup({ sourceType: "planning", sourceId: `PS-5783-${index}`, branchName: `fusion/groups/fn-5783-gate-${index}`, autoMerge: true });
await store.setTaskBranchGroup(task.id, group.id);
await store.updateTask(task.id, {
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
} as any);
await aiMergeTask(store, rootDir, task.id);
expect(findGateEvent(store, group.id)?.metadata).toMatchObject({ groupId: group.id, groupAutoMerge: true, effectiveEligible: false, reason: scenario.reason });
} finally {
await fixture.cleanup();
}
}
}, 60_000);
});

View File

@@ -1,8 +1,8 @@
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";
import type { BranchGroup, MergeTargetResolution, Settings, Task, TaskStore } from "@fusion/core";
import { resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget } from "@fusion/core";
const execAsync = promisify(exec);
@@ -11,6 +11,33 @@ export interface BranchGroupMergeRouting {
mergeTarget: MergeTargetResolution;
}
export type BranchGroupPromotionEligibilityReason =
| "group-automerge-disabled"
| "global-pause"
| "engine-paused"
| "settings-automerge-disabled"
| "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) {
return { eligible: false, reason: "global-pause", groupAutoMerge };
}
if (settings.enginePaused) {
return { eligible: false, reason: "engine-paused", groupAutoMerge };
}
if (!settings.autoMerge) {
return { eligible: false, reason: "settings-automerge-disabled", groupAutoMerge };
}
return { eligible: true, reason: "eligible", groupAutoMerge };
}
async function ensureGroupBranchExists(rootDir: string, branchName: string, startPoint: string): Promise<void> {
const quotedBranch = JSON.stringify(`refs/heads/${branchName}`);
try {

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 { resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js";
import { isGroupPromotionAutoMergeEligible, 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";
@@ -5924,14 +5924,10 @@ export interface MergerOptions {
signal?: AbortSignal;
/** AgentStore for resolving per-agent custom instructions. */
agentStore?: import("@fusion/core").AgentStore;
/** Allow synchronization when local checkout is dirty during merge reconciliation. */
allowDirtyLocalCheckoutSync?: boolean;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
/**
* Escape hatch for trusted callers that want the AI merge path to stash/pop
* dirty edits in the checked-out integration worktree instead of failing
* closed. Defaults false because project-root dirt contaminates later merges.
*/
allowDirtyLocalCheckoutSync?: boolean;
}
function quoteArg(value: string): string {
@@ -7450,8 +7446,13 @@ export async function aiMergeTask(
}
};
if (groupRouting) {
const promotionEligibility = isGroupPromotionAutoMergeEligible(groupRouting.branchGroup, settings);
const auditRunId = `merge-${taskId}`;
try {
await (store as any).recordRunAuditEvent?.({
taskId,
agentId: "merger",
runId: auditRunId,
domain: "git",
mutationType: "merge:branch-group-routed",
target: taskId,
@@ -7462,6 +7463,21 @@ 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
}