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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7750-automerge-hold-stale-branch-group.md
Normal file
7
.changeset/fn-7750-automerge-hold-stale-branch-group.md
Normal file
@@ -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).
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
isTaskReadyForMerge,
|
isTaskReadyForMerge,
|
||||||
allowsAutoMergeProcessing,
|
allowsAutoMergeProcessing,
|
||||||
isSharedBranchGroupMemberIntegration,
|
isSharedBranchGroupMemberIntegration,
|
||||||
|
isLiveSharedBranchGroupMemberIntegration,
|
||||||
resolveEffectiveAutoMerge,
|
resolveEffectiveAutoMerge,
|
||||||
resolveEffectiveGroupAutoMerge,
|
resolveEffectiveGroupAutoMerge,
|
||||||
resolveTaskMergeTarget,
|
resolveTaskMergeTarget,
|
||||||
@@ -164,14 +165,16 @@ describe("resolveEffectiveGroupAutoMerge", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("isSharedBranchGroupMemberIntegration", () => {
|
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", () => {
|
it("returns true for shared members with a resolvable group id", () => {
|
||||||
expect(isSharedBranchGroupMemberIntegration({
|
expect(isSharedBranchGroupMemberIntegration(sharedTask)).toBe(true);
|
||||||
branchContext: {
|
|
||||||
assignmentMode: "shared",
|
|
||||||
groupId: "BG-1",
|
|
||||||
source: "planning",
|
|
||||||
},
|
|
||||||
})).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false for per-task-derived grouped members", () => {
|
it("returns false for per-task-derived grouped members", () => {
|
||||||
@@ -197,6 +200,31 @@ describe("isSharedBranchGroupMemberIntegration", () => {
|
|||||||
it("returns false when branch context is absent", () => {
|
it("returns false when branch context is absent", () => {
|
||||||
expect(isSharedBranchGroupMemberIntegration({ branchContext: undefined })).toBe(false);
|
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", () => {
|
describe("resolveTaskMergeTarget", () => {
|
||||||
|
|||||||
@@ -904,6 +904,7 @@ export {
|
|||||||
isTaskReadyForMerge,
|
isTaskReadyForMerge,
|
||||||
allowsAutoMergeProcessing,
|
allowsAutoMergeProcessing,
|
||||||
isSharedBranchGroupMemberIntegration,
|
isSharedBranchGroupMemberIntegration,
|
||||||
|
isLiveSharedBranchGroupMemberIntegration,
|
||||||
resolveEffectiveAutoMerge,
|
resolveEffectiveAutoMerge,
|
||||||
resolveEffectiveGroupAutoMerge,
|
resolveEffectiveGroupAutoMerge,
|
||||||
resolveTaskMergeTarget,
|
resolveTaskMergeTarget,
|
||||||
|
|||||||
@@ -913,6 +913,7 @@ export {
|
|||||||
isTaskReadyForMerge,
|
isTaskReadyForMerge,
|
||||||
allowsAutoMergeProcessing,
|
allowsAutoMergeProcessing,
|
||||||
isSharedBranchGroupMemberIntegration,
|
isSharedBranchGroupMemberIntegration,
|
||||||
|
isLiveSharedBranchGroupMemberIntegration,
|
||||||
resolveEffectiveAutoMerge,
|
resolveEffectiveAutoMerge,
|
||||||
resolveEffectiveGroupAutoMerge,
|
resolveEffectiveGroupAutoMerge,
|
||||||
resolveTaskMergeTarget,
|
resolveTaskMergeTarget,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ 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(... ) || 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(
|
export function allowsAutoMergeProcessing(
|
||||||
task: Pick<Task, "autoMerge" | "prInfo" | "prInfos">,
|
task: Pick<Task, "autoMerge" | "prInfo" | "prInfos">,
|
||||||
@@ -93,6 +93,17 @@ export function isSharedBranchGroupMemberIntegration(
|
|||||||
&& Boolean(task.branchContext.groupId?.trim());
|
&& 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<Task, "branchContext">,
|
||||||
|
group: Pick<BranchGroup, "status"> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return isSharedBranchGroupMemberIntegration(task) && group != null && group.status === "open";
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveTaskMergeTarget(
|
export function resolveTaskMergeTarget(
|
||||||
task: Pick<Task, "baseBranch" | "branchContext">,
|
task: Pick<Task, "baseBranch" | "branchContext">,
|
||||||
options: MergeTargetResolverOptions = {},
|
options: MergeTargetResolverOptions = {},
|
||||||
|
|||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -244,6 +244,7 @@ function createMockStore(initialSettings: Record<string, unknown>) {
|
|||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
addTaskComment: vi.fn(async () => undefined),
|
addTaskComment: vi.fn(async () => undefined),
|
||||||
getActiveMergingTask: vi.fn(() => null),
|
getActiveMergingTask: vi.fn(() => null),
|
||||||
|
getBranchGroup: vi.fn(() => null),
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise<void>) => {
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise<void>) => {
|
||||||
if (event === "settings:updated") {
|
if (event === "settings:updated") {
|
||||||
settingsHandlers.add(handler as (payload: SettingsHandlerPayload) => void | Promise<void>);
|
settingsHandlers.add(handler as (payload: SettingsHandlerPayload) => void | Promise<void>);
|
||||||
@@ -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 () => {
|
it("startup merge sweep enqueues shared-group members when autoMerge is false", async () => {
|
||||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: false });
|
const mockStore = createMockStore({ ...baseSettings, autoMerge: false });
|
||||||
|
mockStore.store.getBranchGroup.mockReturnValue({ id: "BG-5819", status: "open", branchName: "fusion/groups/bg-5819" });
|
||||||
mockStore.store.listTasks.mockResolvedValueOnce([
|
mockStore.store.listTasks.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
id: "FN-shared",
|
id: "FN-shared",
|
||||||
@@ -3321,8 +3323,8 @@ 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 }) =>
|
const gate = (task: Partial<Task>, settings: { autoMerge: boolean }, branchGroup: { status: "open" | "finalized" | "abandoned" } | null = null) =>
|
||||||
(createEngine() as any).allowInReviewMergeProcessing(task, settings) as boolean;
|
(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", () => {
|
it("lets an explicit per-task autoMerge:true through when the global setting is off", () => {
|
||||||
expect(gate({ autoMerge: true }, { autoMerge: false })).toBe(true);
|
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);
|
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(
|
expect(gate(
|
||||||
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
|
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
|
||||||
{ autoMerge: false },
|
{ autoMerge: false },
|
||||||
|
{ status: "open" },
|
||||||
)).toBe(true);
|
)).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
|
// ## Surface Enumeration
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|||||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
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 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 { 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 { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||||
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||||
@@ -8015,6 +8015,12 @@ export class TaskExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isLiveSharedBranchGroupMember(live: Pick<TaskDetail, "branchContext">): boolean {
|
||||||
|
const groupId = live.branchContext?.groupId?.trim();
|
||||||
|
const branchGroup = groupId ? this.store.getBranchGroup(groupId) : null;
|
||||||
|
return isLiveSharedBranchGroupMemberIntegration(live, branchGroup);
|
||||||
|
}
|
||||||
|
|
||||||
private async routeRetryableRemediationGraphFailureToPreMergeFix(
|
private async routeRetryableRemediationGraphFailureToPreMergeFix(
|
||||||
live: TaskDetail,
|
live: TaskDetail,
|
||||||
failedNode: string | undefined,
|
failedNode: string | undefined,
|
||||||
@@ -8030,7 +8036,8 @@ export class TaskExecutor {
|
|||||||
if (!live.worktree) return false;
|
if (!live.worktree) return false;
|
||||||
const settings = await this.store.getSettings().catch(() => undefined);
|
const settings = await this.store.getSettings().catch(() => undefined);
|
||||||
if (!settings || settings.globalPause === true || settings.enginePaused === true) return false;
|
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);
|
const target = this.latestFailedPreMergeWorkflowStep(live);
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
const budget = await this.resolveFailedPreMergeWorkflowStepBudget(live, target);
|
const budget = await this.resolveFailedPreMergeWorkflowStepBudget(live, target);
|
||||||
@@ -8084,7 +8091,7 @@ export class TaskExecutor {
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const sharedBranchMember = isSharedBranchGroupMemberIntegration(live);
|
const sharedBranchMember = this.isLiveSharedBranchGroupMember(live);
|
||||||
if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false;
|
if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false;
|
||||||
if (!sharedBranchMember && resolveEffectiveAutoMerge(live, settings) === false) return false;
|
if (!sharedBranchMember && resolveEffectiveAutoMerge(live, settings) === false) return false;
|
||||||
if ((live.mergeRetries ?? 0) >= resolveMaxAutoMergeRetries(settings)) return false;
|
if ((live.mergeRetries ?? 0) >= resolveMaxAutoMergeRetries(settings)) return false;
|
||||||
@@ -8152,7 +8159,8 @@ export class TaskExecutor {
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
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;
|
return !allowsAutoMergeProcessing(live, settings) || resolveEffectiveAutoMerge(live, settings) === false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8193,7 +8201,7 @@ export class TaskExecutor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (settings.globalPause === true || settings.enginePaused === true) 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.clearPausedAborted(live.id);
|
||||||
this.activeWorktrees.delete(live.id);
|
this.activeWorktrees.delete(live.id);
|
||||||
@@ -8266,7 +8274,7 @@ export class TaskExecutor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (settings.globalPause === true || settings.enginePaused === true) 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;
|
const nextRetries = priorRetries + 1;
|
||||||
this.clearPausedAborted(live.id);
|
this.clearPausedAborted(live.id);
|
||||||
@@ -8372,7 +8380,7 @@ export class TaskExecutor {
|
|||||||
if (live.column === "in-review") {
|
if (live.column === "in-review") {
|
||||||
if (live.autoMerge === false) return false;
|
if (live.autoMerge === false) return false;
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
const sharedBranchMember = isSharedBranchGroupMemberIntegration(live);
|
const sharedBranchMember = this.isLiveSharedBranchGroupMember(live);
|
||||||
if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false;
|
if (!sharedBranchMember && !allowsAutoMergeProcessing(live, settings)) return false;
|
||||||
if (live.mergeDetails?.mergeConfirmed === true) return false;
|
if (live.mergeDetails?.mergeConfirmed === true) return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
emitOverseerRetry,
|
emitOverseerRetry,
|
||||||
emitOverseerSteering,
|
emitOverseerSteering,
|
||||||
getTaskHardMergeBlocker,
|
getTaskHardMergeBlocker,
|
||||||
|
isLiveSharedBranchGroupMemberIntegration,
|
||||||
isSharedBranchGroupMemberIntegration,
|
isSharedBranchGroupMemberIntegration,
|
||||||
isWorkspaceTask,
|
isWorkspaceTask,
|
||||||
normalizeMergerMode,
|
normalizeMergerMode,
|
||||||
@@ -1817,8 +1818,8 @@ export class ProjectEngine {
|
|||||||
async requestInterpreterMerge(taskId: string, options: { signal?: AbortSignal } = {}): Promise<MergeResult> {
|
async requestInterpreterMerge(taskId: string, options: { signal?: AbortSignal } = {}): Promise<MergeResult> {
|
||||||
let task: Task | null = null;
|
let task: Task | null = null;
|
||||||
let settings: Settings | undefined;
|
let settings: Settings | undefined;
|
||||||
|
const store = this.runtime.getTaskStore();
|
||||||
try {
|
try {
|
||||||
const store = this.runtime.getTaskStore();
|
|
||||||
settings = await store.getSettings();
|
settings = await store.getSettings();
|
||||||
task = await store.getTask(taskId);
|
task = await store.getTask(taskId);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1827,7 +1828,7 @@ export class ProjectEngine {
|
|||||||
const eligible = !!task && !!settings
|
const eligible = !!task && !!settings
|
||||||
&& task.column === "in-review"
|
&& task.column === "in-review"
|
||||||
&& !settings.globalPause && !settings.enginePaused
|
&& !settings.globalPause && !settings.enginePaused
|
||||||
&& this.allowInReviewMergeProcessing(task, settings)
|
&& this.allowInReviewMergeProcessing(task, settings, store)
|
||||||
&& !(task.paused && !task.mergeDetails?.mergeConfirmed);
|
&& !(task.paused && !task.mergeDetails?.mergeConfirmed);
|
||||||
if (!eligible) {
|
if (!eligible) {
|
||||||
// A null task means the lookup failed or the task was deleted; never hand
|
// 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
|
* pushed wins. listTasks returns createdAt ASC — without this sort an
|
||||||
* older low-priority task would start before a later urgent one.
|
* older low-priority task would start before a later urgent one.
|
||||||
*/
|
*/
|
||||||
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">): boolean {
|
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">, store: Partial<Pick<TaskStore, "getBranchGroup">> = this.runtime.getTaskStore()): boolean {
|
||||||
return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task);
|
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<void> {
|
private async emitLegacyAutoMergeStampAdvisory(store: TaskStore): Promise<void> {
|
||||||
@@ -2330,7 +2337,7 @@ export class ProjectEngine {
|
|||||||
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge" | "maxAutoMergeRetries">): number {
|
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge" | "maxAutoMergeRetries">): number {
|
||||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
||||||
const eligible = sortTasksByPriorityThenAgeAndId(
|
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) {
|
for (const t of eligible) {
|
||||||
this.internalEnqueueMerge(t.id);
|
this.internalEnqueueMerge(t.id);
|
||||||
@@ -2544,7 +2551,7 @@ export class ProjectEngine {
|
|||||||
if (!task || task.column !== "in-review") {
|
if (!task || task.column !== "in-review") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!this.allowInReviewMergeProcessing(task, settings)) {
|
if (!this.allowInReviewMergeProcessing(task, settings, store)) {
|
||||||
runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`);
|
runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2586,9 +2593,16 @@ export class ProjectEngine {
|
|||||||
// silently promote the poisoned row to `done` — exactly the
|
// silently promote the poisoned row to `done` — exactly the
|
||||||
// false-positive completion class that lost FN-5612/5613/5614/5616/
|
// false-positive completion class that lost FN-5612/5613/5614/5616/
|
||||||
// 5623/5625 work on 2026-05-27/28.
|
// 5623/5625 work on 2026-05-27/28.
|
||||||
const branchGroupForFastPath = isSharedBranchGroupMemberIntegration(task)
|
const branchGroupForFastPathCandidate = isSharedBranchGroupMemberIntegration(task)
|
||||||
? (store as any).getBranchGroup?.(task.branchContext?.groupId)
|
? (store as any).getBranchGroup?.(task.branchContext?.groupId)
|
||||||
: null;
|
: 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 routedFastPathTarget = branchGroupForFastPath?.branchName?.trim();
|
||||||
const integrationBranchForGate =
|
const integrationBranchForGate =
|
||||||
routedFastPathTarget || task.mergeDetails.mergeTargetBranch || task.baseBranch || "main";
|
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`);
|
runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: ${settings.globalPause ? "globalPause" : "enginePaused"} active`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.allowInReviewMergeProcessing(latestTask, settings)) {
|
if (!this.allowInReviewMergeProcessing(latestTask, settings, store)) {
|
||||||
runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: autoMerge disabled`);
|
runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: autoMerge disabled`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4166,7 +4180,7 @@ export class ProjectEngine {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await store.getSettings();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (this.options.getTaskMergeBlocker?.(task)) {
|
if (this.options.getTaskMergeBlocker?.(task)) {
|
||||||
|
|||||||
@@ -5935,6 +5935,7 @@ export class SelfHealingManager {
|
|||||||
t.column === "in-review" &&
|
t.column === "in-review" &&
|
||||||
allowsAutoMergeProcessing(t, settings) &&
|
allowsAutoMergeProcessing(t, settings) &&
|
||||||
!t.paused &&
|
!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) &&
|
!isSharedBranchGroupMemberIntegration(t) &&
|
||||||
// FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate):
|
// 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`
|
// This no-op finalize classifies one branch against one base over `this.options.rootDir`
|
||||||
|
|||||||
Reference in New Issue
Block a user