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:
5
.changeset/fn-5788-branch-group-promotion-gate.md
Normal file
5
.changeset/fn-5788-branch-group-promotion-gate.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a branch-group promotion eligibility hook to the engine merge lifecycle via `evaluateBranchGroupPromotion`, and emit `merge:branch-group-promotion-gated` audit telemetry whenever shared-group member landings are evaluated for downstream group→default promotion readiness.
|
||||||
@@ -167,6 +167,7 @@ When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a huma
|
|||||||
- FN-5754 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts` guards startup/maintenance stranded-feature re-triage for active autopilot slices, including link-first dedupe, non-defined skip safety, non-autopilot no-op, idempotency, and `mission:stranded-feature-triaged` audit shape.
|
- FN-5754 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts` guards startup/maintenance stranded-feature re-triage for active autopilot slices, including link-first dedupe, non-defined skip safety, non-autopilot no-op, idempotency, and `mission:stranded-feature-triaged` audit shape.
|
||||||
- FN-5755 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission validation coverage so bounded periodic maintenance replays `recoverActiveMissions` for stranded `implementing` features and remains idempotent on repeated passes.
|
- FN-5755 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission validation coverage so bounded periodic maintenance replays `recoverActiveMissions` for stranded `implementing` features and remains idempotent on repeated passes.
|
||||||
- FN-5783 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` guards grouped merge precedence so per-task `autoMerge` remains member→integration only, group `autoMerge` gates promotion eligibility, and promotion-gate audit events capture pause/automerge override reasons.
|
- FN-5783 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` guards grouped merge precedence so per-task `autoMerge` remains member→integration only, group `autoMerge` gates promotion eligibility, and promotion-gate audit events capture pause/automerge override reasons.
|
||||||
|
- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards merger-side promotion-gate telemetry on shared member landings, including pause/settings/group autoMerge reason mapping and no default-branch auto-promotion side effects.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1575,7 +1575,7 @@ The GitHub tracking state listener now attaches to every registered project stor
|
|||||||
### Merge strategies
|
### Merge strategies
|
||||||
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
|
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
|
||||||
- `aiMergeTask()` in `merger.ts` performs merge flow
|
- `aiMergeTask()` in `merger.ts` performs merge flow
|
||||||
- FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. Promotion of the group branch back to the project default branch remains a downstream task.
|
- FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. FN-5788 adds a callable promotion-decision hook (`evaluateBranchGroupPromotion`) and `merge:branch-group-promotion-gated` telemetry at member-landing time; actual group-branch→default ref-advance / PR-merge promotion machinery remains downstream in FN-5784/FN-5785.
|
||||||
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`.
|
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`.
|
||||||
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
|
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
|
||||||
- Supports workflow-step execution after merge (post-merge phase)
|
- Supports workflow-step execution after merge (post-merge phase)
|
||||||
@@ -1773,6 +1773,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
|||||||
- FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` covers bounded no-progress reclaim/resume detection, preserve-work escalation to `todo`, `task:resume-limbo-escalated` audit metadata, progress-signal reset behavior, and user-paused/autoMerge-off non-escalation guards.
|
- FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` covers bounded no-progress reclaim/resume detection, preserve-work escalation to `todo`, `task:resume-limbo-escalated` audit metadata, progress-signal reset behavior, and user-paused/autoMerge-off non-escalation guards.
|
||||||
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` locks the mission-validation trigger invariant so done mission-linked tasks still start validation when the mission loop was stopped, startup recovery replays done implementing features with unpassed assertions, and recovery remains idempotent for already-passed features.
|
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` locks the mission-validation trigger invariant so done mission-linked tasks still start validation when the mission loop was stopped, startup recovery replays done implementing features with unpassed assertions, and recovery remains idempotent for already-passed features.
|
||||||
- FN-5782 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts` guards branch-group merge routing so `shared` members land on `branch_groups.branchName`, grouped multi-member merges converge on the same integration branch, ungrouped/`per-task-derived` tasks stay on direct default-branch merge flow, and routed merges emit `merge:branch-group-routed` audit metadata.
|
- FN-5782 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts` guards branch-group merge routing so `shared` members land on `branch_groups.branchName`, grouped multi-member merges converge on the same integration branch, ungrouped/`per-task-derived` tasks stay on direct default-branch merge flow, and routed merges emit `merge:branch-group-routed` audit metadata.
|
||||||
|
- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards the promotion eligibility hook/audit seam so member landings emit `merge:branch-group-promotion-gated` with deterministic reason metadata (`eligible`, `group-automerge-disabled`, `settings-automerge-disabled`, `global-pause`, `engine-paused`) while group branches remain open and do not auto-promote to the default branch.
|
||||||
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes.
|
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes.
|
||||||
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions.
|
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions.
|
||||||
- FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe invariants: legacy dependency satisfaction remains authoritative while parity diffs emit, and shadow dequeue selection never advances `manual-required` rows.
|
- FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe invariants: legacy dependency satisfaction remains authoritative while parity diffs emit, and shadow dequeue selection never advances `manual-required` rows.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
import { describe, expect, it, afterEach } from "vitest";
|
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[] = [];
|
const dirs: string[] = [];
|
||||||
|
|
||||||
@@ -24,6 +24,99 @@ afterEach(async () => {
|
|||||||
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
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", () => {
|
describe("resolveBranchGroupMergeRouting", () => {
|
||||||
it("returns null for non-shared tasks", async () => {
|
it("returns null for non-shared tasks", async () => {
|
||||||
const routing = await resolveBranchGroupMergeRouting({
|
const routing = await resolveBranchGroupMergeRouting({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,28 +11,40 @@ export interface BranchGroupMergeRouting {
|
|||||||
mergeTarget: MergeTargetResolution;
|
mergeTarget: MergeTargetResolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BranchGroupPromotionEligibilityReason =
|
export interface BranchGroupPromotionDecision {
|
||||||
| "group-automerge-disabled"
|
eligible: boolean;
|
||||||
| "global-pause"
|
groupAutoMerge: boolean;
|
||||||
| "engine-paused"
|
reason:
|
||||||
| "settings-automerge-disabled"
|
| "group-automerge-disabled"
|
||||||
| "eligible";
|
| "settings-automerge-disabled"
|
||||||
|
| "global-pause"
|
||||||
|
| "engine-paused"
|
||||||
|
| "eligible";
|
||||||
|
}
|
||||||
|
|
||||||
export function isGroupPromotionAutoMergeEligible(
|
/**
|
||||||
group: Pick<BranchGroup, "autoMerge">,
|
* Evaluates branch-group → default-branch PROMOTION eligibility only.
|
||||||
settings: Pick<Settings, "autoMerge" | "globalPause" | "enginePaused">,
|
* This does not perform promotion and does not govern member → group-integration
|
||||||
): { eligible: boolean; reason: BranchGroupPromotionEligibilityReason; groupAutoMerge: boolean } {
|
* routing, which stays on the FN-5782 task-level auto-merge path.
|
||||||
const groupAutoMerge = resolveEffectiveGroupAutoMerge(group, settings);
|
*/
|
||||||
if (!groupAutoMerge) {
|
export function evaluateBranchGroupPromotion(input: {
|
||||||
return { eligible: false, reason: "group-automerge-disabled", groupAutoMerge };
|
group: Pick<BranchGroup, "id" | "branchName" | "autoMerge" | "status">;
|
||||||
}
|
settings: Pick<Settings, "autoMerge" | "globalPause" | "enginePaused">;
|
||||||
if (settings.globalPause) {
|
}): BranchGroupPromotionDecision {
|
||||||
|
const groupAutoMerge = resolveEffectiveGroupAutoMerge(input.group, input.settings);
|
||||||
|
if (input.settings.globalPause) {
|
||||||
return { eligible: false, reason: "global-pause", groupAutoMerge };
|
return { eligible: false, reason: "global-pause", groupAutoMerge };
|
||||||
}
|
}
|
||||||
if (settings.enginePaused) {
|
if (input.settings.enginePaused) {
|
||||||
return { eligible: false, reason: "engine-paused", groupAutoMerge };
|
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: false, reason: "settings-automerge-disabled", groupAutoMerge };
|
||||||
}
|
}
|
||||||
return { eligible: true, reason: "eligible", groupAutoMerge };
|
return { eligible: true, reason: "eligible", groupAutoMerge };
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export {
|
|||||||
} from "./integration-branch.js";
|
} from "./integration-branch.js";
|
||||||
export {
|
export {
|
||||||
resolveBranchGroupMergeRouting,
|
resolveBranchGroupMergeRouting,
|
||||||
|
evaluateBranchGroupPromotion,
|
||||||
type BranchGroupMergeRouting,
|
type BranchGroupMergeRouting,
|
||||||
|
type BranchGroupPromotionDecision,
|
||||||
} from "./group-merge-coordinator.js";
|
} from "./group-merge-coordinator.js";
|
||||||
export {
|
export {
|
||||||
resolveMergeIntegrationRoot,
|
resolveMergeIntegrationRoot,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ import {
|
|||||||
} from "./merger-integration-worktree.js";
|
} from "./merger-integration-worktree.js";
|
||||||
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
||||||
import { resolveIntegrationBranch } from "./integration-branch.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 { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
|
||||||
import { syncWorktreeToHead, type SyncWorktreeResult } from "./worktree-ref-sync.js";
|
import { syncWorktreeToHead, type SyncWorktreeResult } from "./worktree-ref-sync.js";
|
||||||
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger-scope-auto-widen.js";
|
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger-scope-auto-widen.js";
|
||||||
@@ -7444,9 +7444,32 @@ export async function aiMergeTask(
|
|||||||
} catch {
|
} catch {
|
||||||
// best-effort persistence
|
// 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) {
|
if (groupRouting) {
|
||||||
const promotionEligibility = isGroupPromotionAutoMergeEligible(groupRouting.branchGroup, settings);
|
|
||||||
const auditRunId = `merge-${taskId}`;
|
const auditRunId = `merge-${taskId}`;
|
||||||
try {
|
try {
|
||||||
await (store as any).recordRunAuditEvent?.({
|
await (store as any).recordRunAuditEvent?.({
|
||||||
@@ -7463,21 +7486,6 @@ export async function aiMergeTask(
|
|||||||
mergeTargetSource: mergeTarget.source,
|
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 {
|
} catch {
|
||||||
// best-effort audit
|
// best-effort audit
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user