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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-03 16:48:25 -07:00
parent 0d492f9056
commit 7824150715
14 changed files with 235 additions and 24 deletions

View File

@@ -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/<mission.id> integration branch.

View File

@@ -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 <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` 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/<id>` 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/<id>` 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. - **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 <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` 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/<id>` 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/<id>` 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. - **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/<id>` 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 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/<id>` 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). - **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. - **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. - **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.

View File

@@ -119,7 +119,7 @@ Missions can also persist a `branchStrategy` used whenever triage is triggered w
Supported modes: Supported modes:
- `project-default` (or absent): shared mode; each triaged feature gets a distinct per-task working branch (for example `<shared-branch>/<feature-id>`) 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/<mission.id>` (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) - `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 - `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 - `custom-new`: shared mode using `branchSelection.mode = "custom-new"` with `branchName` as the shared merge-target branch

View File

@@ -11,12 +11,14 @@ import { describe, expect, it } from "vitest";
describe("MissionStore synchronous triage auto-merge contract", () => { describe("MissionStore synchronous triage auto-merge contract", () => {
it("stamps only false on the synchronous create branch after the duplicate guard", async () => { 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(")); const triageFeature = source.slice(source.indexOf("async triageFeature("), source.indexOf("async triageSlice("));
expect(triageFeature).toContain('if (guard.action === "duplicate" && guard.existing)'); expect(triageFeature).toContain('if (guard.action === "duplicate" && guard.existing)');
expect(triageFeature).toContain("...(mission?.autoMerge === false ? { autoMerge: false } : {}),"); expect(triageFeature).toContain("...(mission?.autoMerge === false ? { autoMerge: false } : {}),");
expect(triageFeature.indexOf('if (guard.action === "duplicate" && guard.existing)')) expect(triageFeature.indexOf('if (guard.action === "duplicate" && guard.existing)'))
.toBeLessThan(triageFeature.indexOf("...(mission?.autoMerge === false ? { autoMerge: false } : {}),")); .toBeLessThan(triageFeature.indexOf("...(mission?.autoMerge === false ? { autoMerge: false } : {}),"));
expect(triageFeature).toContain('usesProjectDefaultStrategy ? `mission/${missionId}`');
expect(triageFeature).toContain('ensureBranchGroupForSource("mission", missionId');
}); });
}); });

View File

@@ -202,9 +202,9 @@ describe("isSharedBranchGroupMemberIntegration", () => {
}); });
it("requires a live open group for auto-merge-off shared-member integration", () => { it("requires a live open group for auto-merge-off shared-member integration", () => {
expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "open" })).toBe(true); expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "open", branchName: "mission/M-3324" }, "main")).toBe(true);
expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "finalized" })).toBe(false); expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "finalized", branchName: "mission/M-3324" }, "main")).toBe(false);
expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "abandoned" })).toBe(false); expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, { status: "abandoned", branchName: "mission/M-3324" }, "main")).toBe(false);
expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, null)).toBe(false); expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, null)).toBe(false);
expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, undefined)).toBe(false); expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, undefined)).toBe(false);
}); });
@@ -216,14 +216,20 @@ describe("isSharedBranchGroupMemberIntegration", () => {
groupId: "BG-1", groupId: "BG-1",
source: "planning", source: "planning",
}, },
}, { status: "open" })).toBe(false); }, { status: "open", branchName: "mission/M-3324" }, "main")).toBe(false);
expect(isLiveSharedBranchGroupMemberIntegration({ expect(isLiveSharedBranchGroupMemberIntegration({
branchContext: { branchContext: {
assignmentMode: "shared", assignmentMode: "shared",
groupId: " ", groupId: " ",
source: "planning", 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);
}); });
}); });

View File

@@ -2355,7 +2355,17 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
const settingsDefaultBranch = const settingsDefaultBranch =
typeof settings.defaultBranch === "string" && settings.defaultBranch.trim().length > 0 ? settings.defaultBranch : "main"; typeof settings.defaultBranch === "string" && settings.defaultBranch.trim().length > 0 ? settings.defaultBranch : "main";
const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false; 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, { const group = await this.taskStore.ensureBranchGroupForSource("mission", missionId, {
branchName: sharedBranchBaseForMission, branchName: sharedBranchBaseForMission,
autoMerge: mission?.autoMerge ?? settingsAutoMerge, autoMerge: mission?.autoMerge ?? settingsAutoMerge,

View File

@@ -63,7 +63,10 @@ export function resolveEffectiveAutoMerge(
* FNXC:PrAutoMergeGate 2026-06-28-00:33: * 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. * 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`. * 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( export function allowsAutoMergeProcessing(
task: Pick<Task, "autoMerge" | "prInfo" | "prInfos">, task: Pick<Task, "autoMerge" | "prInfo" | "prInfos">,
@@ -96,12 +99,26 @@ export function isSharedBranchGroupMemberIntegration(
/** /**
* FNXC:AutoMergeHold 2026-07-09-16:42: * 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. * 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( export function isLiveSharedBranchGroupMemberIntegration(
task: Pick<Task, "branchContext">, task: Pick<Task, "branchContext">,
group: Pick<BranchGroup, "status"> | null | undefined, group: Pick<BranchGroup, "status" | "branchName"> | null | undefined,
projectDefaultBranch?: string,
): boolean { ): 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( export function resolveTaskMergeTarget(

View File

@@ -4231,7 +4231,16 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
? settings.defaultBranch ? settings.defaultBranch
: "main"; : "main";
const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false; 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, { const group = await this.taskStore.ensureBranchGroupForSource("mission", missionId, {
branchName: sharedBranchBaseForMission, branchName: sharedBranchBaseForMission,
autoMerge: mission?.autoMerge ?? settingsAutoMerge, autoMerge: mission?.autoMerge ?? settingsAutoMerge,

View File

@@ -36,7 +36,7 @@ function makeInReviewTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
} as TaskDetail; } as TaskDetail;
} }
function makeExecutor(branchGroup: { status: "open" | "finalized" | "abandoned" } | null) { function makeExecutor(branchGroup: { status: "open" | "finalized" | "abandoned"; branchName: string } | null) {
const store = createMockStore(); const store = createMockStore();
store.getSettings.mockResolvedValue({ store.getSettings.mockResolvedValue({
maxConcurrent: 2, 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 () => { 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(); const task = makeInReviewTask();
await expect((executor as any).isRetryableBenignMergePauseAbort( await expect((executor as any).isRetryableBenignMergePauseAbort(
@@ -82,4 +82,16 @@ describe("executor shared-branch autoMerge:false liveness gates", () => {
true, true,
)).resolves.toBe(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);
});
}); });

View File

@@ -1,10 +1,10 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdtempSync } from "node:fs"; import { mkdtempSync, writeFileSync } from "node:fs";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; 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 { type TaskStore } from "@fusion/core";
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js"; import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
import { import {
@@ -15,9 +15,14 @@ import {
resolveBranchGroupMergeRouting, resolveBranchGroupMergeRouting,
} from "../merge/group-merge-coordinator.js"; } from "../merge/group-merge-coordinator.js";
import { ProjectEngine } from "../project-engine.js"; import { ProjectEngine } from "../project-engine.js";
import { runAiMerge } from "../merge/merger-ai.js";
const dirs: string[] = []; const dirs: string[] = [];
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf8" }).trim();
}
function makeRepo(): string { function makeRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "fusion-group-route-")); const dir = mkdtempSync(join(tmpdir(), "fusion-group-route-"));
dirs.push(dir); 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<string, any> {
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<string, any>, branchGroup: Record<string, any> | 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<string, unknown>) => 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", () => { 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({
@@ -1254,6 +1313,61 @@ describe("resolveBranchGroupMergeRouting", () => {
expect(routing?.mergeTarget.source).toBe("branch-group-integration"); 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 () => { it("creates the group branch when missing", async () => {
const rootDir = makeRepo(); const rootDir = makeRepo();
const branchGroup = { const branchGroup = {

View File

@@ -3670,7 +3670,7 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => {
}); });
describe("allowInReviewMergeProcessing per-task autoMerge override", () => { describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
const gate = (task: Partial<Task>, settings: { autoMerge: boolean }, branchGroup: { status: "open" | "finalized" | "abandoned" } | null = null): Promise<boolean> => const gate = (task: Partial<Task>, settings: { autoMerge: boolean; integrationBranch?: string }, branchGroup: { status: "open" | "finalized" | "abandoned"; branchName?: string } | null = null): Promise<boolean> =>
(createEngine() as any).allowInReviewMergeProcessing(task, settings, { getBranchGroup: vi.fn(() => branchGroup) }); (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 () => { 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); 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( await expect(gate(
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
{ autoMerge: false }, { autoMerge: false, integrationBranch: "main" },
{ status: "open" }, { status: "open", branchName: "mission/M-3324" },
)).resolves.toBe(true); )).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([ it.each([
["missing", null], ["missing", null],
["finalized", { status: "finalized" as const }], ["finalized", { status: "finalized" as const }],

View File

@@ -10551,7 +10551,9 @@ export class TaskExecutor {
const groupId = live.branchContext?.groupId?.trim(); const groupId = live.branchContext?.groupId?.trim();
// FNXC:PostgresCutover 2026-07-10: getBranchGroup is async on the PG branch. // FNXC:PostgresCutover 2026-07-10: getBranchGroup is async on the PG branch.
const branchGroup = groupId ? await this.store.getBranchGroup(groupId) : null; 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( private async routeRetryableRemediationGraphFailureToPreMergeFix(

View File

@@ -547,6 +547,19 @@ export async function resolveBranchGroupMergeRouting(input: {
return null; 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) { if (input.rootDir) {
await ensureGroupBranchExists(input.rootDir, branchGroup.branchName, input.projectDefaultBranch); await ensureGroupBranchExists(input.rootDir, branchGroup.branchName, input.projectDefaultBranch);
} }

View File

@@ -47,6 +47,7 @@ import {
clearMergeConfirmedTransientStatus, clearMergeConfirmedTransientStatus,
} from "@fusion/core"; } from "@fusion/core";
import { assemblePlannerOverseerRuntimeSnapshot } from "./overseer/planner-overseer-runtime-snapshot.js"; import { assemblePlannerOverseerRuntimeSnapshot } from "./overseer/planner-overseer-runtime-snapshot.js";
import { resolveIntegrationBranch } from "./merge/integration-branch.js";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; 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. * older low-priority task would start before a later urgent one.
*/ */
private async allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">, store: Partial<Pick<TaskStore, "getBranchGroup">> = this.runtime.getTaskStore()): Promise<boolean> { private async allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">, store: Partial<Pick<TaskStore, "getBranchGroup">> = this.runtime.getTaskStore()): Promise<boolean> {
if (allowsAutoMergeProcessing(task, settings)) {
return true;
}
const groupId = task.branchContext?.groupId?.trim(); const groupId = task.branchContext?.groupId?.trim();
const branchGroup = groupId ? await store.getBranchGroup?.(groupId) : null; 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: 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. 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<void> { private async emitLegacyAutoMergeStampAdvisory(store: TaskStore): Promise<void> {
@@ -3466,7 +3476,8 @@ export class ProjectEngine {
FNXC:AutoMergeHold 2026-07-09-16:58: 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. 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 ? branchGroupForFastPathCandidate
: null; : null;
const routedFastPathTarget = branchGroupForFastPath?.branchName?.trim(); const routedFastPathTarget = branchGroupForFastPath?.branchName?.trim();