From 7824150715aab77c6aa7146436631bec4b5520bc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 3 Aug 2026 16:48:25 -0700 Subject: [PATCH] FN-8769: protect default-branch mission merges Keep mission group members behind manual release controls when their target is the default branch. - Create deterministic intermediate branches for project-default mission groups. - Gate default-branch group routing and auto-merge exemptions behind the normal manual-release flow. - Cover intermediate and default-branch group behavior with core and engine tests. Files changed: ...fn-8769-default-branch-group-auto-merge-gate.md | 7 ++ docs/architecture.md | 2 +- docs/missions.md | 2 +- .../mission-store.sync-auto-merge.test.ts | 4 +- packages/core/src/__tests__/task-merge.test.ts | 16 ++- .../core/src/async-stores/async-mission-store.ts | 12 ++- packages/core/src/merge/task-merge.ts | 23 +++- packages/core/src/missions/mission-store.ts | 11 +- ...cutor-live-branch-group-auto-merge-hold.test.ts | 16 ++- .../src/__tests__/group-merge-coordinator.test.ts | 118 ++++++++++++++++++++- .../engine/src/__tests__/project-engine.test.ts | 16 ++- packages/engine/src/executor.ts | 4 +- .../engine/src/merge/group-merge-coordinator.ts | 13 +++ packages/engine/src/project-engine.ts | 15 ++- 14 files changed, 235 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8769 Fusion-Task-Lineage: dff96c8e-ca96-437c-94bf-9691bdf572e9 Co-authored-by: Fusion (runfusion.ai) --- ...69-default-branch-group-auto-merge-gate.md | 7 ++ docs/architecture.md | 2 +- docs/missions.md | 2 +- .../mission-store.sync-auto-merge.test.ts | 4 +- .../core/src/__tests__/task-merge.test.ts | 16 ++- .../src/async-stores/async-mission-store.ts | 12 +- packages/core/src/merge/task-merge.ts | 23 +++- packages/core/src/missions/mission-store.ts | 11 +- ...-live-branch-group-auto-merge-hold.test.ts | 16 ++- .../__tests__/group-merge-coordinator.test.ts | 118 +++++++++++++++++- .../src/__tests__/project-engine.test.ts | 16 ++- packages/engine/src/executor.ts | 4 +- .../src/merge/group-merge-coordinator.ts | 13 ++ packages/engine/src/project-engine.ts | 15 ++- 14 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 .changeset/fn-8769-default-branch-group-auto-merge-gate.md diff --git a/.changeset/fn-8769-default-branch-group-auto-merge-gate.md b/.changeset/fn-8769-default-branch-group-auto-merge-gate.md new file mode 100644 index 0000000000..50d828b0c2 --- /dev/null +++ b/.changeset/fn-8769-default-branch-group-auto-merge-gate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Protect default-branch mission group merges with the normal manual release gate. +category: fix +dev: Mission default strategies now reuse a dedicated mission/ integration branch. diff --git a/docs/architecture.md b/docs/architecture.md index a089c36c58..eeb694d92c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2271,7 +2271,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count ..` is > 0, and `git diff --quiet ..` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases. - **Verified no-op/duplicate executor completion (FN-6275/FN-7488)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`), the task already carries a no-commit contract, or the PROMPT declares a source-free gitignored task-artifact delivery. The source-free path is intentionally narrow: File Scope must be populated and limited to board/task artifacts such as `.fusion/tasks/...`, task documents/logs, or attachments; the prompt must forbid force-adding ignored `.fusion/` artifacts and fabricating empty commits or equivalently state that source-free/gitignored task artifacts are the only deliverables; and any tracked source/docs/config/test/changeset scope keeps the `no_commits` refusal active (even if `.fusion/` artifacts are also listed). These exemptions only relax the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Prompt-derived source-free completions log `prompt-derived source-free task-artifact contract` for operator audit. Ordinary zero-commit implementation completions without one of these contracts are still refused. - **In-review branch-binding self-heal (FN-5083/FN-6695)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Unsafe metadata repair is also skipped with `task:auto-rebind-skipped`: `userPaused` preserves authoritative user intent, and `checkedOutBy` preserves live agent checkout ownership. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. -- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. That includes merge-active unusable-worktree recovery: automation emits `task:reconcile-missing-worktree-merge-active-no-action` with `reason:"auto-merge-off"` instead of moving the row, while explicit operator retry remains supported because it is a manual reset request. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. FN-7182 applies the same human-gated treatment to an open `PrInfo.manual` PR created or linked from the dashboard **Create PR** action: automatic merge queues and self-healing stand down until the PR is closed/merged or handled manually, while pipeline-created PRs without `manual` remain auto-merge eligible. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. +- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. That includes merge-active unusable-worktree recovery: automation emits `task:reconcile-missing-worktree-merge-active-no-action` with `reason:"auto-merge-off"` instead of moving the row, while explicit operator retry remains supported because it is a manual reset request. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off only when the group is open and its normalized branch differs from the resolved project default branch. This is a soft pre-integration only and does not permit shared-branch → default-branch promotion; a default-branch collision follows the normal manual-release path. FN-7182 applies the same human-gated treatment to an open `PrInfo.manual` PR created or linked from the dashboard **Create PR** action: automatic merge queues and self-healing stand down until the PR is closed/merged or handled manually, while pipeline-created PRs without `manual` remain auto-merge eligible. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. - **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode). - **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off. - **Self-owned reclaim resume-limbo escalation (FN-5704)**: `reclaimSelfOwnedBranchConflicts` tracks `resumeLimboCount`, `resumeLimboTipSha`, and `resumeLimboStepSignature` for in-progress reclaim/unpause loops. If reclaim finds no progress (same tip, same step-status signature, and no active-session signal) for `MAX_NO_PROGRESS_RESUME_ATTEMPTS` consecutive sweeps, self-healing escalates by moving the task to `todo` with `preserveWorktree: true`, `preserveProgress: true`, and `preserveResumeState: true` instead of endlessly re-arming resume. Escalation emits `task:resume-limbo-escalated` run-audit metadata (`frozenTipSha`, `idleMs`, `resumeAttemptCount`, `currentStep`) and resets the limbo counter. diff --git a/docs/missions.md b/docs/missions.md index ec4b93eec1..1a523d2ca2 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -119,7 +119,7 @@ Missions can also persist a `branchStrategy` used whenever triage is triggered w Supported modes: -- `project-default` (or absent): shared mode; each triaged feature gets a distinct per-task working branch (for example `/`) while the shared branch remains the mission group merge target +- `project-default` (or absent): shared mode; triage creates and reuses the deterministic group integration branch `mission/` (for example `mission/M-3324`). Each feature gets a distinct per-task working branch while that branch remains the group merge target. Existing persisted default-branch groups are not rewritten; their members remain protected by the normal manual-release gate. - `auto-per-task`: sets `branchAssignment.mode = "per-task-derived"` (distinct per-task working branches with no shared mission group merge target) - `existing`: shared mode using `branchSelection.mode = "existing"` with `branchName` as the shared merge-target branch - `custom-new`: shared mode using `branchSelection.mode = "custom-new"` with `branchName` as the shared merge-target branch diff --git a/packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts b/packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts index c6e67c8c7c..4feeacada8 100644 --- a/packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts +++ b/packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts @@ -11,12 +11,14 @@ import { describe, expect, it } from "vitest"; describe("MissionStore synchronous triage auto-merge contract", () => { it("stamps only false on the synchronous create branch after the duplicate guard", async () => { - const source = await readFile(fileURLToPath(new URL("../mission-store.ts", import.meta.url)), "utf8"); + const source = await readFile(fileURLToPath(new URL("../missions/mission-store.ts", import.meta.url)), "utf8"); const triageFeature = source.slice(source.indexOf("async triageFeature("), source.indexOf("async triageSlice(")); expect(triageFeature).toContain('if (guard.action === "duplicate" && guard.existing)'); expect(triageFeature).toContain("...(mission?.autoMerge === false ? { autoMerge: false } : {}),"); expect(triageFeature.indexOf('if (guard.action === "duplicate" && guard.existing)')) .toBeLessThan(triageFeature.indexOf("...(mission?.autoMerge === false ? { autoMerge: false } : {}),")); + expect(triageFeature).toContain('usesProjectDefaultStrategy ? `mission/${missionId}`'); + expect(triageFeature).toContain('ensureBranchGroupForSource("mission", missionId'); }); }); diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index 7ffbea19b2..f85fd9363a 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -202,9 +202,9 @@ describe("isSharedBranchGroupMemberIntegration", () => { }); it("requires a live open group for auto-merge-off shared-member integration", () => { - expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "open" })).toBe(true); - expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "finalized" })).toBe(false); - expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "abandoned" })).toBe(false); + expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "open", branchName: "mission/M-3324" }, "main")).toBe(true); + expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "finalized", branchName: "mission/M-3324" }, "main")).toBe(false); + expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "abandoned", branchName: "mission/M-3324" }, "main")).toBe(false); expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, null)).toBe(false); expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, undefined)).toBe(false); }); @@ -216,14 +216,20 @@ describe("isSharedBranchGroupMemberIntegration", () => { groupId: "BG-1", source: "planning", }, - }, { status: "open" })).toBe(false); + }, { status: "open", branchName: "mission/M-3324" }, "main")).toBe(false); expect(isLiveSharedBranchGroupMemberIntegration({ branchContext: { assignmentMode: "shared", groupId: " ", source: "planning", }, - }, { status: "open" })).toBe(false); + }, { status: "open", branchName: "mission/M-3324" }, "main")).toBe(false); + }); + + it("withholds the exemption for blank or default-branch group targets", () => { + expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "open", branchName: " " }, "main")).toBe(false); + expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "open", branchName: " main " }, "main")).toBe(false); + expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "open", branchName: "release/main" }, "main")).toBe(true); }); }); diff --git a/packages/core/src/async-stores/async-mission-store.ts b/packages/core/src/async-stores/async-mission-store.ts index 62096671f0..6a9d9fd550 100644 --- a/packages/core/src/async-stores/async-mission-store.ts +++ b/packages/core/src/async-stores/async-mission-store.ts @@ -2355,7 +2355,17 @@ export class AsyncMissionStore extends EventEmitter { const settingsDefaultBranch = typeof settings.defaultBranch === "string" && settings.defaultBranch.trim().length > 0 ? settings.defaultBranch : "main"; const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false; - sharedBranchBaseForMission = resolvedBranch ?? resolvedBaseBranch ?? settingsDefaultBranch; + /* + FNXC:BranchGroupAutoMergeGate 2026-08-03-23:17: + Runfusion/Fusion#3324: an absent or project-default mission strategy + must create one reusable intermediate branch, never a group that + points at the default branch and bypasses the operator merge hold. + Existing source-identity groups are deliberately reused unchanged for + legacy compatibility; runtime merge gating protects old main groups. + */ + const usesProjectDefaultStrategy = !mission?.branchStrategy || mission.branchStrategy.mode === "project-default"; + sharedBranchBaseForMission = resolvedBranch + ?? (usesProjectDefaultStrategy ? `mission/${missionId}` : resolvedBaseBranch ?? settingsDefaultBranch); const group = await this.taskStore.ensureBranchGroupForSource("mission", missionId, { branchName: sharedBranchBaseForMission, autoMerge: mission?.autoMerge ?? settingsAutoMerge, diff --git a/packages/core/src/merge/task-merge.ts b/packages/core/src/merge/task-merge.ts index 84ed8618d2..0744271dc8 100644 --- a/packages/core/src/merge/task-merge.ts +++ b/packages/core/src/merge/task-merge.ts @@ -63,7 +63,10 @@ export function resolveEffectiveAutoMerge( * FNXC:PrAutoMergeGate 2026-06-28-00:33: * FN-7182: a dashboard-created open PR is a human handoff, so exclude it from all automatic merge processing and self-healing recovery until the human merges or closes the PR. * This mirrors the `autoMerge:false` in-review gate while preserving manual Merge PR/manual done paths and pipeline PRs without `manual: true`. - * Shared-branch member integration still bypasses this function via `allowInReviewMergeProcessing(... ) || isLiveSharedBranchGroupMemberIntegration(task, group)`, so a manual PR on a live shared member can still be integrated to its group branch; group-to-default promotion remains gated separately. + * Shared-branch member integration still bypasses this function only through + * `isLiveSharedBranchGroupMemberIntegration(task, group, defaultBranch)`: its + * live group branch must be a distinct intermediate target. Group-to-default + * promotion remains gated separately. */ export function allowsAutoMergeProcessing( task: Pick, @@ -96,12 +99,26 @@ export function isSharedBranchGroupMemberIntegration( /** * FNXC:AutoMergeHold 2026-07-09-16:42: * FN-7750 / Runfusion#1980: the `autoMerge:false` exemption for shared-branch members is valid only while the branch group is live. Missing, finalized, abandoned, or dissolved groups must degrade to the standalone manual-hold path so operator Merge & Close control is honored regardless of whether the task was API-, user-, or engine-created. + * + * FNXC:BranchGroupAutoMergeGate 2026-08-03-23:17: + * Runfusion/Fusion#3324 requires human release controls to apply whenever a + * shared-group member would land on the resolved default branch. Only a live, + * nonblank intermediate branch distinct from that default may bypass + * `autoMerge:false`; shape-only callers must keep using + * `isSharedBranchGroupMemberIntegration` so stale members remain excluded from + * solo finalization. */ export function isLiveSharedBranchGroupMemberIntegration( task: Pick, - group: Pick | null | undefined, + group: Pick | null | undefined, + projectDefaultBranch?: string, ): boolean { - return isSharedBranchGroupMemberIntegration(task) && group != null && group.status === "open"; + const groupBranch = group?.branchName?.trim(); + const defaultBranch = projectDefaultBranch?.trim() || "main"; + return isSharedBranchGroupMemberIntegration(task) + && group?.status === "open" + && Boolean(groupBranch) + && groupBranch !== defaultBranch; } export function resolveTaskMergeTarget( diff --git a/packages/core/src/missions/mission-store.ts b/packages/core/src/missions/mission-store.ts index 88d0f133b8..359bfea129 100644 --- a/packages/core/src/missions/mission-store.ts +++ b/packages/core/src/missions/mission-store.ts @@ -4231,7 +4231,16 @@ export class MissionStore extends EventEmitter { ? settings.defaultBranch : "main"; const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false; - sharedBranchBaseForMission = resolvedBranch ?? resolvedBaseBranch ?? settingsDefaultBranch; + /* + FNXC:BranchGroupAutoMergeGate 2026-08-03-23:17: + Runfusion/Fusion#3324: absent/project-default mission strategies need + a deterministic intermediate branch so member integration cannot + bypass human release controls by targeting the project default. + Source-identity reuse intentionally preserves legacy persisted groups. + */ + const usesProjectDefaultStrategy = !mission?.branchStrategy || mission.branchStrategy.mode === "project-default"; + sharedBranchBaseForMission = resolvedBranch + ?? (usesProjectDefaultStrategy ? `mission/${missionId}` : resolvedBaseBranch ?? settingsDefaultBranch); const group = await this.taskStore.ensureBranchGroupForSource("mission", missionId, { branchName: sharedBranchBaseForMission, autoMerge: mission?.autoMerge ?? settingsAutoMerge, diff --git a/packages/engine/src/__tests__/executor-live-branch-group-auto-merge-hold.test.ts b/packages/engine/src/__tests__/executor-live-branch-group-auto-merge-hold.test.ts index 646f04383f..890c42fe95 100644 --- a/packages/engine/src/__tests__/executor-live-branch-group-auto-merge-hold.test.ts +++ b/packages/engine/src/__tests__/executor-live-branch-group-auto-merge-hold.test.ts @@ -36,7 +36,7 @@ function makeInReviewTask(overrides: Partial = {}): TaskDetail { } as TaskDetail; } -function makeExecutor(branchGroup: { status: "open" | "finalized" | "abandoned" } | null) { +function makeExecutor(branchGroup: { status: "open" | "finalized" | "abandoned"; branchName: string } | null) { const store = createMockStore(); store.getSettings.mockResolvedValue({ maxConcurrent: 2, @@ -72,7 +72,7 @@ describe("executor shared-branch autoMerge:false liveness gates", () => { }); it("still routes live shared-group members through the local integration retry gate", async () => { - const { executor } = makeExecutor({ status: "open" }); + const { executor } = makeExecutor({ status: "open", branchName: "mission/M-1980" }); const task = makeInReviewTask(); await expect((executor as any).isRetryableBenignMergePauseAbort( @@ -82,4 +82,16 @@ describe("executor shared-branch autoMerge:false liveness gates", () => { true, )).resolves.toBe(true); }); + + it("holds an open shared group that would integrate directly into main", async () => { + const { executor } = makeExecutor({ status: "open", branchName: "main" }); + const task = makeInReviewTask(); + + await expect((executor as any).isRetryableBenignMergePauseAbort( + task, + mergeAbortResult, + "merge-seam", + true, + )).resolves.toBe(false); + }); }); diff --git a/packages/engine/src/__tests__/group-merge-coordinator.test.ts b/packages/engine/src/__tests__/group-merge-coordinator.test.ts index 408a6da45b..da60eb5c4c 100644 --- a/packages/engine/src/__tests__/group-merge-coordinator.test.ts +++ b/packages/engine/src/__tests__/group-merge-coordinator.test.ts @@ -1,10 +1,10 @@ import { execSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { describe, expect, it, afterEach, beforeEach } from "vitest"; +import { describe, expect, it, afterEach, beforeEach, vi } from "vitest"; import { type TaskStore } from "@fusion/core"; import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js"; import { @@ -15,9 +15,14 @@ import { resolveBranchGroupMergeRouting, } from "../merge/group-merge-coordinator.js"; import { ProjectEngine } from "../project-engine.js"; +import { runAiMerge } from "../merge/merger-ai.js"; const dirs: string[] = []; +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf8" }).trim(); +} + function makeRepo(): string { const dir = mkdtempSync(join(tmpdir(), "fusion-group-route-")); dirs.push(dir); @@ -1218,6 +1223,60 @@ describe("reconcileBranchGroupPr (Fix #3 engine primitive)", () => { }); }); +/* +FNXC:BranchGroupAutoMergeGate 2026-08-03-23:30: +Runfusion/Fusion#3324 requires the post-Code-Review merge seam to leave a member targeting the +project default branch under human release control. This fixture reaches ProjectEngine's interpreter +merge request, then uses the production AI merger against real Git only for the distinct-branch +control, proving the safety gate prevents a command from advancing main rather than merely relabeling it. +*/ +function createPostReviewTask(groupId: string): Record { + return { + id: "FN-3324", + title: "post-Code-Review member", + description: "Regression fixture for Runfusion/Fusion#3324", + column: "in-review", + status: null, + branch: "fusion/fn-3324", + baseBranch: "main", + dependencies: [], + steps: [{ name: "Code Review", status: "done" }], + log: [], + paused: false, + autoMerge: undefined, + mergeRetries: 0, + branchContext: { assignmentMode: "shared", groupId, source: "mission" }, + }; +} + +function createPostReviewStore(task: Record, branchGroup: Record | null) { + return { + getTask: vi.fn(async () => task), + getSettings: vi.fn(async () => ({ + autoMerge: false, + merger: { maxReviewPasses: 0 }, + includeTaskIdInCommit: false, + mergeIntegrationWorktree: "cwd-main", + mergeStrategy: "direct", + directMergeCommitStrategy: "auto", + })), + getBranchGroup: vi.fn(() => branchGroup), + updateTask: vi.fn(async (_id: string, patch: Record) => Object.assign(task, patch)), + moveTask: vi.fn(async (_id: string, column: string) => { task.column = column; return task; }), + logEntry: vi.fn(async () => undefined), + appendAgentLog: vi.fn(async () => undefined), + recordRunAuditEvent: vi.fn(async () => undefined), + emit: vi.fn(), + } as any; +} + +function createInterpreterMergeEngine(repo: string, store: any): any { + const engine = Object.create(ProjectEngine.prototype) as any; + engine.config = { workingDirectory: repo }; + engine.runtime = { getTaskStore: () => store }; + return engine; +} + describe("resolveBranchGroupMergeRouting", () => { it("returns null for non-shared tasks", async () => { const routing = await resolveBranchGroupMergeRouting({ @@ -1254,6 +1313,61 @@ describe("resolveBranchGroupMergeRouting", () => { expect(routing?.mergeTarget.source).toBe("branch-group-integration"); }); + it("does not route a default-branch group as ungated member integration", async () => { + const routing = await resolveBranchGroupMergeRouting({ + task: { branchContext: { groupId: "BG-main", source: "mission", assignmentMode: "shared" } }, + store: { getBranchGroup: () => ({ id: "BG-main", branchName: " main ", status: "open" }) } as any, + projectDefaultBranch: "main", + }); + + expect(routing).toBeNull(); + }); + + it("keeps the post-Code-Review main collision manual while merging the dedicated-branch control", async () => { + const repo = makeRepo(); + const mainBefore = git(repo, "git rev-parse main"); + git(repo, "git checkout -q -b fusion/fn-3324"); + writeFileSync(join(repo, "feature.txt"), "feature work\n"); + git(repo, "git add feature.txt && git commit -q -m feature"); + git(repo, "git checkout -q main"); + + const unsafeTask = createPostReviewTask("BG-main"); + const unsafeStore = createPostReviewStore(unsafeTask, { id: "BG-main", status: "open", branchName: "main" }); + const unsafeEngine = createInterpreterMergeEngine(repo, unsafeStore); + unsafeEngine.onMerge = vi.fn(async () => { throw new Error("main collision must not invoke the merger"); }); + + const held = await unsafeEngine.requestInterpreterMerge("FN-3324"); + + expect(held).toMatchObject({ merged: false, noOp: true, task: unsafeTask }); + expect(unsafeEngine.onMerge).not.toHaveBeenCalled(); + expect(git(repo, "git rev-parse main")).toBe(mainBefore); + expect(unsafeTask.mergeDetails).toBeUndefined(); + + git(repo, "git branch mission/M-3324 main"); + const safeTask = createPostReviewTask("BG-dedicated"); + const safeStore = createPostReviewStore(safeTask, { id: "BG-dedicated", status: "open", branchName: "mission/M-3324" }); + const safeEngine = createInterpreterMergeEngine(repo, safeStore); + safeEngine.onMerge = vi.fn(() => runAiMerge(safeStore, repo, "FN-3324", { manual: true }, { + mergeAgent: async (cwd: string) => { + git(cwd, "git merge --squash fusion/fn-3324"); + git(cwd, "git add -A && git commit -q -m 'squash dedicated member'"); + }, + reviewAgent: async () => "REVIEW_VERDICT: approve", + })); + + const integrated = await safeEngine.requestInterpreterMerge("FN-3324"); + + expect(integrated.merged).toBe(true); + expect(safeEngine.onMerge).toHaveBeenCalledWith("FN-3324", expect.any(Object)); + expect(git(repo, "git rev-parse main")).toBe(mainBefore); + expect(git(repo, "git show mission/M-3324:feature.txt")).toBe("feature work"); + expect(safeTask.mergeDetails).toMatchObject({ + mergeConfirmed: true, + mergeTargetBranch: "mission/M-3324", + mergeTargetSource: "branch-group-integration", + }); + }, 30_000); + it("creates the group branch when missing", async () => { const rootDir = makeRepo(); const branchGroup = { diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 9da69d6d42..24cb955bf8 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -3670,7 +3670,7 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => { }); describe("allowInReviewMergeProcessing per-task autoMerge override", () => { - const gate = (task: Partial, settings: { autoMerge: boolean }, branchGroup: { status: "open" | "finalized" | "abandoned" } | null = null): Promise => + const gate = (task: Partial, settings: { autoMerge: boolean; integrationBranch?: string }, branchGroup: { status: "open" | "finalized" | "abandoned"; branchName?: string } | null = null): Promise => (createEngine() as any).allowInReviewMergeProcessing(task, settings, { getBranchGroup: vi.fn(() => branchGroup) }); it("lets an explicit per-task autoMerge:true through when the global setting is off", async () => { @@ -3687,14 +3687,22 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => { await expect(gate({ autoMerge: false }, { autoMerge: true })).resolves.toBe(true); }); - it("still exempts live shared-branch-group member integration when the global setting is off", async () => { + it("still exempts live shared-branch-group member integration on an intermediate branch when the global setting is off", async () => { await expect(gate( { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, - { autoMerge: false }, - { status: "open" }, + { autoMerge: false, integrationBranch: "main" }, + { status: "open", branchName: "mission/M-3324" }, )).resolves.toBe(true); }); + it("keeps live shared-branch-group member integration on the default branch behind the manual gate", async () => { + await expect(gate( + { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, + { autoMerge: false, integrationBranch: "main" }, + { status: "open", branchName: "main" }, + )).resolves.toBe(false); + }); + it.each([ ["missing", null], ["finalized", { status: "finalized" as const }], diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 75acf4e5c5..08694c4645 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -10551,7 +10551,9 @@ export class TaskExecutor { const groupId = live.branchContext?.groupId?.trim(); // FNXC:PostgresCutover 2026-07-10: getBranchGroup is async on the PG branch. const branchGroup = groupId ? await this.store.getBranchGroup(groupId) : null; - return isLiveSharedBranchGroupMemberIntegration(live, branchGroup); + const settings = await this.store.getSettings(); + const projectDefaultBranch = await resolveIntegrationBranch(this.rootDir, settings); + return isLiveSharedBranchGroupMemberIntegration(live, branchGroup, projectDefaultBranch); } private async routeRetryableRemediationGraphFailureToPreMergeFix( diff --git a/packages/engine/src/merge/group-merge-coordinator.ts b/packages/engine/src/merge/group-merge-coordinator.ts index 8bc1ef7d60..ce0eeb1ab0 100644 --- a/packages/engine/src/merge/group-merge-coordinator.ts +++ b/packages/engine/src/merge/group-merge-coordinator.ts @@ -547,6 +547,19 @@ export async function resolveBranchGroupMergeRouting(input: { return null; } + /* + FNXC:BranchGroupAutoMergeGate 2026-08-03-23:17: + Runfusion/Fusion#3324 permits the member-integration route only for a live + intermediate branch. A terminal group or a normalized default-branch target + must fall back to the normal task merge route, where the human release gate + applies; never label a direct default-branch landing as group integration. + */ + const groupBranch = branchGroup.branchName.trim(); + const defaultBranch = input.projectDefaultBranch.trim() || "main"; + if (branchGroup.status !== "open" || !groupBranch || groupBranch === defaultBranch) { + return null; + } + if (input.rootDir) { await ensureGroupBranchExists(input.rootDir, branchGroup.branchName, input.projectDefaultBranch); } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index de725c3a4f..5275d83f11 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -47,6 +47,7 @@ import { clearMergeConfirmedTransientStatus, } from "@fusion/core"; import { assemblePlannerOverseerRuntimeSnapshot } from "./overseer/planner-overseer-runtime-snapshot.js"; +import { resolveIntegrationBranch } from "./merge/integration-branch.js"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -2910,13 +2911,22 @@ export class ProjectEngine { * older low-priority task would start before a later urgent one. */ private async allowInReviewMergeProcessing(task: Pick, settings: Pick, store: Partial> = this.runtime.getTaskStore()): Promise { + if (allowsAutoMergeProcessing(task, settings)) { + return true; + } + const groupId = task.branchContext?.groupId?.trim(); const branchGroup = groupId ? await store.getBranchGroup?.(groupId) : null; + if (!branchGroup || branchGroup.status !== "open" || !branchGroup.branchName.trim()) { + return false; + } + + const projectDefaultBranch = await resolveIntegrationBranch(this.config.workingDirectory, settings as Settings); /* FNXC:AutoMergeHold 2026-07-09-16:53: FN-7750 / Runfusion#1980: shared-branch member integration may bypass the global `autoMerge:false` hold only while its group row is still open. Stale, finalized, abandoned, or missing groups must flow through the standalone manual-hold gate so no task provenance can solo auto-merge to main. */ - return allowsAutoMergeProcessing(task, settings) || isLiveSharedBranchGroupMemberIntegration(task, branchGroup); + return isLiveSharedBranchGroupMemberIntegration(task, branchGroup, projectDefaultBranch); } private async emitLegacyAutoMergeStampAdvisory(store: TaskStore): Promise { @@ -3466,7 +3476,8 @@ export class ProjectEngine { FNXC:AutoMergeHold 2026-07-09-16:58: FN-7750: merge-confirmed fast-path rerouting to a branch-group integration branch is safe only for a live/open group. A missing or terminal group must leave the row on its stored standalone target instead of reviving a stale group route that could bypass the manual merge hold. */ - const branchGroupForFastPath = isLiveSharedBranchGroupMemberIntegration(task, branchGroupForFastPathCandidate) + const fastPathDefaultBranch = await resolveIntegrationBranch(this.config.workingDirectory, settings); + const branchGroupForFastPath = isLiveSharedBranchGroupMemberIntegration(task, branchGroupForFastPathCandidate, fastPathDefaultBranch) ? branchGroupForFastPathCandidate : null; const routedFastPathTarget = branchGroupForFastPath?.branchName?.trim();