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:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user