FN-5819: allow shared-group members to merge into group branch with auto-merge off

Allow shared branch-group members to continue member→group integration even when project auto-merge is disabled.

- add core helper to detect shared branch-group member integrations and export it
- update engine in-review enqueue/handoff/startup flows to permit shared-member integration while still honoring global/engine pause gates
- keep self-healing from pulling shared-member in-review tasks backward during auto-merge-off maintenance
- expand project-engine and reliability-interaction tests for shared-member autoMerge-off behavior and add FN-5819 backstop docs updates

Files changed:
 AGENTS.md                                          |  2 +
 docs/architecture.md                               |  3 +-
 packages/core/src/__tests__/task-merge.test.ts     | 37 +++++++++
 packages/core/src/index.ts                         |  1 +
 packages/core/src/task-merge.ts                    | 13 +++
 packages/engine/src/__tests__/project-engine.test.ts    | 64 ++++++++++++++
 packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts             | 48 +++++++++++
 packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts        | 97 ++++++++++++++++++++++
 packages/engine/src/project-engine.ts              | 35 ++++----
 packages/engine/src/self-healing.ts                |  3 +-
 10 files changed, 284 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-5819

Fusion-Task-Lineage: b5eeced7-7c47-4573-9ab4-ca577bec65e1
This commit is contained in:
gsxdsm
2026-06-01 02:42:36 -07:00
parent e854d33375
commit 35ce9bd244
10 changed files with 284 additions and 19 deletions

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
@@ -1879,6 +1880,69 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
await engine.stop();
});
it("startup merge sweep enqueues shared-group members when autoMerge is false", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: false });
mockStore.store.listTasks.mockResolvedValueOnce([
{
id: "FN-shared",
column: "in-review",
paused: false,
mergeRetries: 0,
status: null,
branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" },
},
{ id: "FN-plain", column: "in-review", paused: false, mergeRetries: 0, status: null },
]);
mocks.currentStore = mockStore.store;
const engine = createEngine();
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
await engine.start();
expect(enqueueSpy).toHaveBeenCalledWith("FN-shared");
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain");
await engine.stop();
});
it("task:moved handoff keeps shared members blocked by global or engine pause", async () => {
vi.useFakeTimers();
for (const settings of [
{ ...baseSettings, autoMerge: false, globalPause: true, enginePaused: false },
{ ...baseSettings, autoMerge: false, globalPause: false, enginePaused: true },
]) {
const mockStore = createMockStore(settings);
mocks.currentStore = mockStore.store;
const engine = createEngine();
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
await engine.start();
enqueueSpy.mockClear();
const movedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
| ((event: { task: Task; to: string }) => void)
| undefined;
if (!movedHandler) throw new Error("task:moved handler was not registered");
movedHandler({
task: {
id: "FN-shared",
column: "in-review",
paused: false,
steps: [],
branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" },
} as unknown as Task,
to: "in-review",
});
await vi.advanceTimersByTimeAsync(350);
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-shared");
await engine.stop();
}
vi.useRealTimers();
});
it("global unpause sweep does not enqueue paused in-review tasks", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mocks.currentStore = mockStore.store;

View File

@@ -74,6 +74,54 @@ describe("FN-5782 reliability interactions: branch group merge routing", () => {
}
}, 30_000);
it.skipIf(!hasGit)("records shared-member landing even when autoMerge is false", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5819-RI-AUTO-OFF",
settings: { testMode: true, autoMerge: false } as any,
});
try {
const { rootDir, store, task } = fixture;
await stageMergeBranch(store, rootDir, task.id, "fn5819AutoOff");
const group = store.createBranchGroup({
sourceType: "planning",
sourceId: "PS-FN5819",
branchName: "fusion/groups/fn-5819-auto-off",
});
await store.setTaskBranchGroup(task.id, group.id);
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5819AutoOff.ts`)).toContain("fn5819AutoOff");
expect(() => git(rootDir, "git show main:packages/engine/src/fn5819AutoOff.ts")).toThrow();
const updatedGroup = store.getBranchGroup(group.id)!;
expect(updatedGroup.status).toBe("open");
expect(updatedGroup.worktreePath).toBe(join(`${rootDir}-worktrees`, task.id.toLowerCase()));
const events = store.getRunAuditEvents().filter((event) => event.target === task.id);
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({
mutationType: "merge:branch-group-routed",
metadata: expect.objectContaining({
mergeTargetBranch: group.branchName,
mergeTargetSource: "branch-group-integration",
}),
}),
expect.objectContaining({
mutationType: "merge:branch-group-promotion-gated",
metadata: expect.objectContaining({
groupId: group.id,
effectiveEligible: false,
}),
}),
]));
} finally {
await fixture.cleanup();
}
}, 45_000);
it.skipIf(!hasGit)("lands two shared members of same group onto one integration branch", async () => {
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-A", settings: { testMode: true } as any });

View File

@@ -0,0 +1,97 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { type TaskStore } from "@fusion/core";
import { aiMergeTask } from "../../merger.js";
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
async function stageMergeBranch(store: TaskStore, rootDir: string, taskId: string, fileName: string): Promise<void> {
const task = await store.getTask(taskId);
const branch = `fusion/${taskId.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, taskId.toLowerCase());
await store.updateTask(taskId, {
baseBranch: "",
branch,
column: "in-review",
worktree: worktreePath,
steps: (task?.steps ?? []).map((step) => ({ ...step, status: "done" as const })),
currentStep: (task?.steps ?? []).length ?? 0,
} as any);
git(rootDir, `git checkout -b ${branch}`);
await mkdir(join(rootDir, "packages/engine/src"), { recursive: true });
git(rootDir, `sh -c 'printf ${JSON.stringify(`export const ${fileName} = true;\n`)} > ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}'`);
git(rootDir, `git add ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}`);
git(rootDir, `git commit -m ${JSON.stringify(`feat: add ${fileName}`)}`);
git(rootDir, "git checkout main");
}
describe("FN-5819 reliability interactions: shared group member integration", () => {
it.skipIf(!hasGit)("keeps shared-member integration forward under autoMerge false", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5819-RI-A",
settings: { testMode: true, autoMerge: false } as any,
});
try {
const { rootDir, store, task, manager } = fixture;
const second = await store.createTask({
id: "FN-5819-RI-B",
title: "FN-5819-RI-B",
description: "second member",
column: "in-review",
baseBranch: "main",
branch: "fusion/fn-5819-ri-b",
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
steps: [],
} as any);
const nongroup = await store.createTask({
id: "FN-5819-RI-NONGROUP",
title: "FN-5819-RI-NONGROUP",
description: "non-group in-review",
column: "in-review",
baseBranch: "main",
branch: "fusion/fn-5819-ri-nongroup",
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
steps: [],
} as any);
const group = store.createBranchGroup({
sourceType: "planning",
sourceId: "PS-FN5819",
branchName: "fusion/groups/fn-5819-shared",
});
await store.setTaskBranchGroup(task.id, group.id);
await store.setTaskBranchGroup(second.id, group.id);
await stageMergeBranch(store, rootDir, task.id, "fn5819MemberA");
await stageMergeBranch(store, rootDir, second.id, "fn5819MemberB");
const first = await aiMergeTask(store, rootDir, task.id);
const secondResult = await aiMergeTask(store, rootDir, second.id);
expect(first.merged).toBe(true);
expect(secondResult.merged).toBe(true);
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5819MemberA.ts`)).toContain("fn5819MemberA");
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5819MemberB.ts`)).toContain("fn5819MemberB");
expect(() => git(rootDir, "git show main:packages/engine/src/fn5819MemberA.ts")).toThrow();
expect(() => git(rootDir, "git show main:packages/engine/src/fn5819MemberB.ts")).toThrow();
const moveSpy = vi.spyOn(store, "moveTask");
await (manager as any).runMaintenance();
expect(moveSpy.mock.calls.some(([id, column]) => id === task.id && column === "todo")).toBe(false);
expect(moveSpy.mock.calls.some(([id, column]) => id === second.id && column === "todo")).toBe(false);
expect(moveSpy.mock.calls.some(([id, column]) => id === task.id && column === "in-progress")).toBe(false);
expect(moveSpy.mock.calls.some(([id, column]) => id === second.id && column === "in-progress")).toBe(false);
const refreshedNonGroup = await store.getTask(nongroup.id);
expect(refreshedNonGroup.column).toBe("in-review");
} finally {
await fixture.cleanup();
}
}, 60_000);
});

View File

@@ -10,7 +10,7 @@ import type {
ScheduledTask,
AutomationRunResult,
} from "@fusion/core";
import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
@@ -1375,9 +1375,13 @@ 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 enqueueEligibleInReviewTasks(tasks: readonly Task[]): number {
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext">, settings: Pick<Settings, "autoMerge">): boolean {
return settings.autoMerge || isSharedBranchGroupMemberIntegration(task);
}
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge">): number {
const eligible = sortTasksByPriorityThenAgeAndId(
tasks.filter((t) => !t.paused && this.canMergeTask(t as any)) as Task[],
tasks.filter((t) => !t.paused && this.canMergeTask(t as any) && this.allowInReviewMergeProcessing(t, settings)) as Task[],
);
for (const t of eligible) {
this.internalEnqueueMerge(t.id);
@@ -1487,15 +1491,14 @@ export class ProjectEngine {
);
continue;
}
if (!settings.autoMerge) {
runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`);
continue;
}
const task = await store.getTask(taskId);
if (!task || task.column !== "in-review") {
continue;
}
if (!this.allowInReviewMergeProcessing(task, settings)) {
runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`);
continue;
}
if (task.paused && !task.mergeDetails?.mergeConfirmed) {
runtimeLog.log(`Auto-merge skipping ${taskId} — task is paused`);
continue;
@@ -2570,7 +2573,7 @@ export class ProjectEngine {
runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: ${settings.globalPause ? "globalPause" : "enginePaused"} active`);
return;
}
if (!settings.autoMerge) {
if (!this.allowInReviewMergeProcessing(latestTask, settings)) {
runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: autoMerge disabled`);
return;
}
@@ -2687,7 +2690,7 @@ export class ProjectEngine {
try {
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused || !settings.autoMerge) {
if (settings.globalPause || settings.enginePaused || !this.allowInReviewMergeProcessing(task, settings)) {
return;
}
if (this.options.getTaskMergeBlocker?.(task)) {
@@ -2759,10 +2762,8 @@ export class ProjectEngine {
}
const settings = await store.getSettings();
if (!settings.autoMerge) return;
const enqueued = this.enqueueEligibleInReviewTasks(tasks as Task[]);
const enqueued = this.enqueueEligibleInReviewTasks(tasks as Task[], settings);
if (enqueued > 0) {
runtimeLog.log(`Auto-merge startup sweep: enqueueing ${enqueued} task(s)`);
}
@@ -2819,9 +2820,9 @@ export class ProjectEngine {
try {
const settings = await store.getSettings();
if (!settings.globalPause && !settings.enginePaused && settings.autoMerge) {
if (!settings.globalPause && !settings.enginePaused) {
const tasks = await store.listTasks({ column: "in-review" });
this.enqueueEligibleInReviewTasks(tasks as Task[]);
this.enqueueEligibleInReviewTasks(tasks as Task[], settings);
}
} catch (err: unknown) {
runtimeLog.warn(
@@ -2876,13 +2877,13 @@ export class ProjectEngine {
);
}
if (settings.globalPause || settings.enginePaused || !settings.autoMerge) {
if (settings.globalPause || settings.enginePaused) {
return;
}
try {
const tasks = await store.listTasks({ column: "in-review" });
this.enqueueEligibleInReviewTasks(tasks as Task[]);
this.enqueueEligibleInReviewTasks(tasks as Task[], settings);
} catch (err: unknown) {
runtimeLog.warn(
`${source}: failed to scan in-review tasks for auto-merge: ${err instanceof Error ? err.message : String(err)}`,

View File

@@ -28,7 +28,7 @@ import { promisify } from "node:util";
import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
@@ -4314,6 +4314,7 @@ export class SelfHealingManager {
const candidates = tasks.filter((t) =>
t.column === "in-review" &&
!t.paused &&
!isSharedBranchGroupMemberIntegration(t) &&
Boolean(t.worktree) &&
t.mergeDetails?.mergeConfirmed !== true &&
t.status !== "merging" &&