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;