From eb377ba831457f14528fd8010ba844d479d9cb64 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 9 Jul 2026 15:25:29 -0700 Subject: [PATCH] FN-7750: gate shared-branch-group auto-merge exemption on live groups Fixes autoMerge=false being bypassed for engine-created branch-group member tasks whose branch group had already dissolved/finalized. - Add isLiveSharedBranchGroupMemberIntegration(task, group) in @fusion/core, requiring the branch group's status be "open" before the shared-branch-member exemption bypasses the global/task autoMerge:false hold. - Export the new helper from packages/core/src/index.ts and index.gate.ts. - Thread the live-group check through packages/engine/src/project-engine.ts (allowInReviewMergeProcessing, enqueueEligibleInReviewTasks, merge-confirmed fast-path branch routing, and merge handoff paths). - Add TaskExecutor.isLiveSharedBranchGroupMember helper in packages/engine/src/executor.ts and use it in retryable pre-merge remediation, no-op finalize, benign pause-abort classification, and merge-processing gates. - Keep self-healing.ts's solo no-op finalize predicate on the pure branchContext-shape check (isSharedBranchGroupMemberIntegration) intentionally, so stale shared-group members stay excluded from solo finalize regardless of group liveness. - Add regression tests covering the executor and project-engine auto-merge-hold behavior for stale/dissolved branch groups. - Add a patch changeset documenting the fix. Files changed: .../fn-7750-automerge-hold-stale-branch-group.md | 7 ++ packages/core/src/__tests__/task-merge.test.ts | 42 +++++++++-- packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/task-merge.ts | 13 +++- ...cutor-live-branch-group-auto-merge-hold.test.ts | 85 ++++++++++++++++++++++ .../engine/src/__tests__/project-engine.test.ts | 37 +++++++++- packages/engine/src/executor.ts | 22 ++++-- packages/engine/src/project-engine.ts | 32 +++++--- packages/engine/src/self-healing.ts | 1 + 10 files changed, 214 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-7750 Fusion-Task-Lineage: d61f8847-0b09-49b5-b66a-00018c8738bb Co-authored-by: Fusion (runfusion.ai) --- ...-7750-automerge-hold-stale-branch-group.md | 7 ++ .../core/src/__tests__/task-merge.test.ts | 42 +++++++-- packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/task-merge.ts | 13 ++- ...-live-branch-group-auto-merge-hold.test.ts | 85 +++++++++++++++++++ .../src/__tests__/project-engine.test.ts | 37 +++++++- packages/engine/src/executor.ts | 22 +++-- packages/engine/src/project-engine.ts | 32 +++++-- packages/engine/src/self-healing.ts | 1 + 10 files changed, 214 insertions(+), 27 deletions(-) create mode 100644 .changeset/fn-7750-automerge-hold-stale-branch-group.md create mode 100644 packages/engine/src/__tests__/executor-live-branch-group-auto-merge-hold.test.ts diff --git a/.changeset/fn-7750-automerge-hold-stale-branch-group.md b/.changeset/fn-7750-automerge-hold-stale-branch-group.md new file mode 100644 index 0000000000..a9c71f1821 --- /dev/null +++ b/.changeset/fn-7750-automerge-hold-stale-branch-group.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Manual merge hold now applies to shared-branch-group tasks whose group has dissolved. +category: fix +dev: `isLiveSharedBranchGroupMemberIntegration(task, group)` gates the shared-member auto-merge-off exemption on a live (`status: "open"`) branch group; a missing/finalized/abandoned group degrades to the standalone manual-hold path. Threaded through `project-engine.ts allowInReviewMergeProcessing` and the `executor.ts` merge gates. Fixes issue #1980 (FN-7750). diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index 3cec6b2f68..c21dbed28d 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -14,6 +14,7 @@ import { isTaskReadyForMerge, allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, + isLiveSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget, @@ -164,14 +165,16 @@ describe("resolveEffectiveGroupAutoMerge", () => { }); describe("isSharedBranchGroupMemberIntegration", () => { + const sharedTask = { + branchContext: { + assignmentMode: "shared" as const, + groupId: "BG-1", + source: "planning" as const, + }, + }; + it("returns true for shared members with a resolvable group id", () => { - expect(isSharedBranchGroupMemberIntegration({ - branchContext: { - assignmentMode: "shared", - groupId: "BG-1", - source: "planning", - }, - })).toBe(true); + expect(isSharedBranchGroupMemberIntegration(sharedTask)).toBe(true); }); it("returns false for per-task-derived grouped members", () => { @@ -197,6 +200,31 @@ describe("isSharedBranchGroupMemberIntegration", () => { it("returns false when branch context is absent", () => { expect(isSharedBranchGroupMemberIntegration({ branchContext: undefined })).toBe(false); }); + + 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, null)).toBe(false); + expect(isLiveSharedBranchGroupMemberIntegration(sharedTask, undefined)).toBe(false); + }); + + it("does not grant the live-group exemption to non-shared or blank-group contexts", () => { + expect(isLiveSharedBranchGroupMemberIntegration({ + branchContext: { + assignmentMode: "per-task-derived", + groupId: "BG-1", + source: "planning", + }, + }, { status: "open" })).toBe(false); + expect(isLiveSharedBranchGroupMemberIntegration({ + branchContext: { + assignmentMode: "shared", + groupId: " ", + source: "planning", + }, + }, { status: "open" })).toBe(false); + }); }); describe("resolveTaskMergeTarget", () => { diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 8f3e61c88e..1e34917192 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -904,6 +904,7 @@ export { isTaskReadyForMerge, allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, + isLiveSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 584861dd58..2228949e0a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -913,6 +913,7 @@ export { isTaskReadyForMerge, allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, + isLiveSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget, diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index 23dd93b914..fd26ba0a57 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -63,7 +63,7 @@ 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(... ) || isSharedBranchGroupMemberIntegration(task)`, so a manual PR on a 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 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. */ export function allowsAutoMergeProcessing( task: Pick, @@ -93,6 +93,17 @@ export function isSharedBranchGroupMemberIntegration( && Boolean(task.branchContext.groupId?.trim()); } +/** + * 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. + */ +export function isLiveSharedBranchGroupMemberIntegration( + task: Pick, + group: Pick | null | undefined, +): boolean { + return isSharedBranchGroupMemberIntegration(task) && group != null && group.status === "open"; +} + export function resolveTaskMergeTarget( task: Pick, options: MergeTargetResolverOptions = {}, 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 new file mode 100644 index 0000000000..646f04383f --- /dev/null +++ b/packages/engine/src/__tests__/executor-live-branch-group-auto-merge-hold.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import "./executor-test-helpers.js"; +import { TaskExecutor } from "../executor.js"; +import { createMockStore } from "./executor-test-helpers.js"; +import type { TaskDetail } from "@fusion/core"; + +const now = "2026-07-09T17:18:00.000Z"; + +function makeInReviewTask(overrides: Partial = {}): TaskDetail { + return { + id: "FN-1980", + title: "engine-created stale branch-group member", + description: "Reproduces Runfusion/Fusion#1980 stale branch-group auto-merge-off bypass", + column: "in-review", + dependencies: [], + steps: [{ name: "Implement", status: "done" }], + currentStep: 0, + log: [], + branch: "fusion/fn-1980", + baseBranch: "main", + worktree: "/tmp/fusion-fn-1980", + status: "reviewing", + error: null, + paused: false, + userPaused: false, + autoMerge: undefined, + mergeRetries: 0, + createdAt: now, + updatedAt: now, + branchContext: { assignmentMode: "shared", groupId: "BG-STALE", source: "mission" }, + sourceType: "unknown", + sourceMetadata: { + fusionBranchContext: { assignmentMode: "shared", groupId: "BG-STALE", source: "mission" }, + }, + ...overrides, + } as TaskDetail; +} + +function makeExecutor(branchGroup: { status: "open" | "finalized" | "abandoned" } | null) { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15_000, + autoMerge: false, + maxAutoMergeRetries: 3, + }); + store.getBranchGroup = vi.fn(() => branchGroup); + const executor = new TaskExecutor(store, "/tmp/test", {}); + return { executor, store }; +} + +const mergeAbortResult = { + visitedNodeIds: ["merge"], + context: { "node:merge:value": "aborted" }, +}; + +describe("executor shared-branch autoMerge:false liveness gates", () => { + it("does not route an engine-created dissolved-group member to auto-merge retry", async () => { + const { executor, store } = makeExecutor(null); + const task = makeInReviewTask(); + + const retryable = await (executor as any).isRetryableBenignMergePauseAbort( + task, + mergeAbortResult, + "merge-seam", + true, + ); + + expect(retryable).toBe(false); + expect(store.getBranchGroup).toHaveBeenCalledWith("BG-STALE"); + }); + + it("still routes live shared-group members through the local integration retry gate", async () => { + const { executor } = makeExecutor({ status: "open" }); + const task = makeInReviewTask(); + + await expect((executor as any).isRetryableBenignMergePauseAbort( + task, + mergeAbortResult, + "merge-seam", + true, + )).resolves.toBe(true); + }); +}); diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 274740ca27..3614d94584 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -244,6 +244,7 @@ function createMockStore(initialSettings: Record) { emit: vi.fn(), addTaskComment: vi.fn(async () => undefined), getActiveMergingTask: vi.fn(() => null), + getBranchGroup: vi.fn(() => null), on: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise) => { if (event === "settings:updated") { settingsHandlers.add(handler as (payload: SettingsHandlerPayload) => void | Promise); @@ -2546,6 +2547,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { it("startup merge sweep enqueues shared-group members when autoMerge is false", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: false }); + mockStore.store.getBranchGroup.mockReturnValue({ id: "BG-5819", status: "open", branchName: "fusion/groups/bg-5819" }); mockStore.store.listTasks.mockResolvedValueOnce([ { id: "FN-shared", @@ -3321,8 +3323,8 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => { }); describe("allowInReviewMergeProcessing per-task autoMerge override", () => { - const gate = (task: Partial, settings: { autoMerge: boolean }) => - (createEngine() as any).allowInReviewMergeProcessing(task, settings) as boolean; + const gate = (task: Partial, settings: { autoMerge: boolean }, branchGroup: { status: "open" | "finalized" | "abandoned" } | null = null) => + (createEngine() as any).allowInReviewMergeProcessing(task, settings, { getBranchGroup: vi.fn(() => branchGroup) }) as boolean; it("lets an explicit per-task autoMerge:true through when the global setting is off", () => { expect(gate({ autoMerge: true }, { autoMerge: false })).toBe(true); @@ -3338,12 +3340,41 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => { expect(gate({ autoMerge: false }, { autoMerge: true })).toBe(true); }); - it("still exempts shared-branch-group member integration when the global setting is off", () => { + it("still exempts live shared-branch-group member integration when the global setting is off", () => { expect(gate( { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, { autoMerge: false }, + { status: "open" }, )).toBe(true); }); + + it.each([ + ["missing", null], + ["finalized", { status: "finalized" as const }], + ["abandoned", { status: "abandoned" as const }], + ])("blocks shared-branch-group member integration for %s groups when global autoMerge is off", (_label, branchGroup) => { + expect(gate( + { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, + { autoMerge: false }, + branchGroup, + )).toBe(false); + }); + + it.each([ + ["api", { sourceType: "api" }], + ["user-created", { sourceType: undefined }], + ["engine-created", { sourceType: "unknown", sourceMetadata: { fusionBranchContext: { assignmentMode: "shared", groupId: "grp-1", source: "mission" } } }], + ])("applies the dissolved-group manual hold regardless of %s provenance", (_label, provenance) => { + expect(gate( + { + ...provenance, + autoMerge: undefined, + branchContext: { assignmentMode: "shared", groupId: "grp-1", source: "mission" } as Task["branchContext"], + }, + { autoMerge: false }, + null, + )).toBe(false); + }); }); // ## Surface Enumeration diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index bb9495bc2e..54f1a82483 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -11,7 +11,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult } from "@fusion/core"; import { getUnmetSchedulingDependencies } from "./scheduler.js"; -import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON } from "@fusion/core"; +import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON } from "@fusion/core"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core"; @@ -8015,6 +8015,12 @@ export class TaskExecutor { }; } + private isLiveSharedBranchGroupMember(live: Pick): boolean { + const groupId = live.branchContext?.groupId?.trim(); + const branchGroup = groupId ? this.store.getBranchGroup(groupId) : null; + return isLiveSharedBranchGroupMemberIntegration(live, branchGroup); + } + private async routeRetryableRemediationGraphFailureToPreMergeFix( live: TaskDetail, failedNode: string | undefined, @@ -8030,7 +8036,8 @@ export class TaskExecutor { if (!live.worktree) return false; const settings = await this.store.getSettings().catch(() => undefined); if (!settings || settings.globalPause === true || settings.enginePaused === true) return false; - if (!allowsAutoMergeProcessing(live, settings) && !isSharedBranchGroupMemberIntegration(live)) return false; + /* FNXC:AutoMergeHold 2026-07-09-17:04: FN-7750 requires retryable pre-merge remediation to treat stale shared-group members as standalone manual-hold rows when global auto-merge is off; only live/open groups retain the shared-member exemption. */ + if (!allowsAutoMergeProcessing(live, settings) && !this.isLiveSharedBranchGroupMember(live)) return false; const target = this.latestFailedPreMergeWorkflowStep(live); if (!target) return false; const budget = await this.resolveFailedPreMergeWorkflowStepBudget(live, target); @@ -8084,7 +8091,7 @@ export class TaskExecutor { } catch { return false; } - const sharedBranchMember = isSharedBranchGroupMemberIntegration(live); + const sharedBranchMember = this.isLiveSharedBranchGroupMember(live); if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false; if (!sharedBranchMember && resolveEffectiveAutoMerge(live, settings) === false) return false; if ((live.mergeRetries ?? 0) >= resolveMaxAutoMergeRetries(settings)) return false; @@ -8152,7 +8159,8 @@ export class TaskExecutor { } catch { return false; } - if (isSharedBranchGroupMemberIntegration(live)) return false; + /* FNXC:AutoMergeHold 2026-07-09-17:07: FN-7749's benign manual-hold classifier must exclude only live shared-group integrations. FN-7750 stale shared-group members are standalone manual-hold rows and should not be stranded as pause-abort failures. */ + if (this.isLiveSharedBranchGroupMember(live)) return false; return !allowsAutoMergeProcessing(live, settings) || resolveEffectiveAutoMerge(live, settings) === false; } @@ -8193,7 +8201,7 @@ export class TaskExecutor { return false; } if (settings.globalPause === true || settings.enginePaused === true) return false; - if (!allowsAutoMergeProcessing(live, settings) && !isSharedBranchGroupMemberIntegration(live)) return false; + if (!allowsAutoMergeProcessing(live, settings) && !this.isLiveSharedBranchGroupMember(live)) return false; this.clearPausedAborted(live.id); this.activeWorktrees.delete(live.id); @@ -8266,7 +8274,7 @@ export class TaskExecutor { return false; } if (settings.globalPause === true || settings.enginePaused === true) return false; - if (!allowsAutoMergeProcessing(live, settings) && !isSharedBranchGroupMemberIntegration(live)) return false; + if (!allowsAutoMergeProcessing(live, settings) && !this.isLiveSharedBranchGroupMember(live)) return false; const nextRetries = priorRetries + 1; this.clearPausedAborted(live.id); @@ -8372,7 +8380,7 @@ export class TaskExecutor { if (live.column === "in-review") { if (live.autoMerge === false) return false; if (!settings) return false; - const sharedBranchMember = isSharedBranchGroupMemberIntegration(live); + const sharedBranchMember = this.isLiveSharedBranchGroupMember(live); if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false; if (live.mergeDetails?.mergeConfirmed === true) return false; } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index b4c26daf89..f2f286f169 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -26,6 +26,7 @@ import { emitOverseerRetry, emitOverseerSteering, getTaskHardMergeBlocker, + isLiveSharedBranchGroupMemberIntegration, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, @@ -1817,8 +1818,8 @@ export class ProjectEngine { async requestInterpreterMerge(taskId: string, options: { signal?: AbortSignal } = {}): Promise { let task: Task | null = null; let settings: Settings | undefined; + const store = this.runtime.getTaskStore(); try { - const store = this.runtime.getTaskStore(); settings = await store.getSettings(); task = await store.getTask(taskId); } catch { @@ -1827,7 +1828,7 @@ export class ProjectEngine { const eligible = !!task && !!settings && task.column === "in-review" && !settings.globalPause && !settings.enginePaused - && this.allowInReviewMergeProcessing(task, settings) + && this.allowInReviewMergeProcessing(task, settings, store) && !(task.paused && !task.mergeDetails?.mergeConfirmed); if (!eligible) { // A null task means the lookup failed or the task was deleted; never hand @@ -2286,8 +2287,14 @@ export class ProjectEngine { * pushed wins. listTasks returns createdAt ASC — without this sort an * older low-priority task would start before a later urgent one. */ - private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { - return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); + private allowInReviewMergeProcessing(task: Pick, settings: Pick, store: Partial> = this.runtime.getTaskStore()): boolean { + const groupId = task.branchContext?.groupId?.trim(); + const branchGroup = groupId ? store.getBranchGroup?.(groupId) : null; + /* + 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); } private async emitLegacyAutoMergeStampAdvisory(store: TaskStore): Promise { @@ -2330,7 +2337,7 @@ export class ProjectEngine { private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick): number { const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); const eligible = sortTasksByPriorityThenAgeAndId( - tasks.filter((t) => !t.paused && this.canMergeTask(t as any, maxAutoMergeRetries) && this.allowInReviewMergeProcessing(t, settings)) as Task[], + tasks.filter((t) => !t.paused && this.canMergeTask(t as any, maxAutoMergeRetries) && this.allowInReviewMergeProcessing(t, settings, this.runtime.getTaskStore())) as Task[], ); for (const t of eligible) { this.internalEnqueueMerge(t.id); @@ -2544,7 +2551,7 @@ export class ProjectEngine { if (!task || task.column !== "in-review") { continue; } - if (!this.allowInReviewMergeProcessing(task, settings)) { + if (!this.allowInReviewMergeProcessing(task, settings, store)) { runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`); continue; } @@ -2586,9 +2593,16 @@ export class ProjectEngine { // silently promote the poisoned row to `done` — exactly the // false-positive completion class that lost FN-5612/5613/5614/5616/ // 5623/5625 work on 2026-05-27/28. - const branchGroupForFastPath = isSharedBranchGroupMemberIntegration(task) + const branchGroupForFastPathCandidate = isSharedBranchGroupMemberIntegration(task) ? (store as any).getBranchGroup?.(task.branchContext?.groupId) : null; + /* + 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) + ? branchGroupForFastPathCandidate + : null; const routedFastPathTarget = branchGroupForFastPath?.branchName?.trim(); const integrationBranchForGate = routedFastPathTarget || task.mergeDetails.mergeTargetBranch || task.baseBranch || "main"; @@ -4049,7 +4063,7 @@ export class ProjectEngine { runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: ${settings.globalPause ? "globalPause" : "enginePaused"} active`); return; } - if (!this.allowInReviewMergeProcessing(latestTask, settings)) { + if (!this.allowInReviewMergeProcessing(latestTask, settings, store)) { runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: autoMerge disabled`); return; } @@ -4166,7 +4180,7 @@ export class ProjectEngine { try { const settings = await store.getSettings(); - if (settings.globalPause || settings.enginePaused || !this.allowInReviewMergeProcessing(task, settings)) { + if (settings.globalPause || settings.enginePaused || !this.allowInReviewMergeProcessing(task, settings, store)) { return; } if (this.options.getTaskMergeBlocker?.(task)) { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index f8e0e97e1c..1758f87050 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -5935,6 +5935,7 @@ export class SelfHealingManager { t.column === "in-review" && allowsAutoMergeProcessing(t, settings) && !t.paused && + // FNXC:AutoMergeHold 2026-07-09-17:10: FN-7750 intentionally keeps the pure branchContext-shape predicate here. Stale shared-group members must stay OUT of solo no-op finalize even when their group is not live; only the positive auto-merge-off exemption gates use the live-group predicate. !isSharedBranchGroupMemberIntegration(t) && // FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): // This no-op finalize classifies one branch against one base over `this.options.rootDir`