FN-5820: add shared-branch-group lifecycle regression coverage
Add end-to-end reliability coverage for shared branch group promotion flow. - add a new reliability interaction test covering concurrent member execution through completion gate and single shared-branch promotion/PR behavior - document FN-5820 backstop coverage in AGENTS.md for future reliability reference Files changed: AGENTS.md | 1 + packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts | 359 +++++++++++++++++++++ 2 files changed, 360 insertions(+) Fusion-Task-Id: FN-5820 Fusion-Task-Lineage: 4c7bbc6f-6bf9-4698-bcb9-43d7c98d5bd7
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type TaskStore } from "@fusion/core";
|
||||
import { evaluateBranchGroupCompletion, promoteBranchGroup } from "../../group-merge-coordinator.js";
|
||||
import { aiMergeTask } from "../../merger.js";
|
||||
import { acquireTaskWorktree } from "../../worktree-acquisition.js";
|
||||
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "../../worktree-names.js";
|
||||
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
type StagedMember = {
|
||||
taskId: string;
|
||||
branch: string;
|
||||
worktreePath: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
async function stageSharedMember(
|
||||
store: TaskStore,
|
||||
rootDir: string,
|
||||
input: { taskId: string; groupId: string; source: "planning" | "mission"; fileName: string },
|
||||
): Promise<StagedMember> {
|
||||
const task = await store.getTask(input.taskId);
|
||||
const branch = `fusion/${input.taskId.toLowerCase()}`;
|
||||
const worktreePath = join(`${rootDir}-worktrees`, input.taskId.toLowerCase());
|
||||
|
||||
await store.updateTask(input.taskId, {
|
||||
baseBranch: "",
|
||||
branch,
|
||||
column: "in-review",
|
||||
branchContext: { groupId: input.groupId, source: input.source, assignmentMode: "shared" },
|
||||
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 ${input.fileName} = true;\n`)} > ${JSON.stringify(`packages/engine/src/${input.fileName}.ts`)}'`);
|
||||
git(rootDir, `git add ${JSON.stringify(`packages/engine/src/${input.fileName}.ts`)}`);
|
||||
git(rootDir, `git commit -m ${JSON.stringify(`feat: add ${input.fileName}`)}`);
|
||||
git(rootDir, "git checkout main");
|
||||
store.enqueueMergeQueue(input.taskId);
|
||||
|
||||
return { taskId: input.taskId, branch, worktreePath, fileName: input.fileName };
|
||||
}
|
||||
|
||||
describe("FN-5820 reliability interactions: shared branch group lifecycle", () => {
|
||||
it.skipIf(!hasGit)("CASE 1: shared members resolve distinct working branches/worktrees without branch conflict", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5820-RI-A", settings: { testMode: true } as any });
|
||||
|
||||
try {
|
||||
const { rootDir, store, task, settings } = fixture;
|
||||
const second = await store.createTask({
|
||||
id: "FN-5820-RI-B",
|
||||
title: "FN-5820-RI-B",
|
||||
description: "second shared member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/groups/fn-5820-shared",
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-FN5820-CASE1",
|
||||
branchName: "fusion/groups/fn-5820-shared",
|
||||
});
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.setTaskBranchGroup(second.id, group.id);
|
||||
|
||||
const firstTask = await store.getTask(task.id);
|
||||
const secondTask = await store.getTask(second.id);
|
||||
const firstBranch = resolveTaskWorkingBranch(firstTask!);
|
||||
const secondBranch = resolveTaskWorkingBranch(secondTask!);
|
||||
expect(firstBranch).toBe(canonicalFusionBranchName(firstTask!.id));
|
||||
expect(secondBranch).toBe(canonicalFusionBranchName(secondTask!.id));
|
||||
expect(firstBranch).not.toBe(secondBranch);
|
||||
expect(firstBranch).not.toBe(group.branchName);
|
||||
expect(secondBranch).not.toBe(group.branchName);
|
||||
|
||||
const firstAcq = await acquireTaskWorktree({ task: firstTask!, rootDir, store, settings, runInitCommand: false });
|
||||
const secondAcq = await acquireTaskWorktree({ task: secondTask!, rootDir, store, settings, runInitCommand: false });
|
||||
expect(firstAcq.branch).toBe(firstBranch);
|
||||
expect(secondAcq.branch).toBe(secondBranch);
|
||||
expect(firstAcq.worktreePath).not.toBe(secondAcq.worktreePath);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it.skipIf(!hasGit)("CASE 2: shared members integrate to common branch and accumulate without landing main", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5820-RI-C", settings: { testMode: true } as any });
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const second = await store.createTask({
|
||||
id: "FN-5820-RI-D",
|
||||
title: "FN-5820-RI-D",
|
||||
description: "second shared member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5820-ri-d",
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "mission",
|
||||
sourceId: "M-FN5820-CASE2",
|
||||
branchName: "fusion/groups/fn-5820-accumulate",
|
||||
});
|
||||
|
||||
await stageSharedMember(store, rootDir, { taskId: task.id, groupId: group.id, source: "mission", fileName: "fn5820Case2A" });
|
||||
await stageSharedMember(store, rootDir, { taskId: second.id, groupId: group.id, source: "mission", fileName: "fn5820Case2B" });
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.setTaskBranchGroup(second.id, group.id);
|
||||
|
||||
const firstResult = await aiMergeTask(store, rootDir, task.id);
|
||||
const secondResult = await aiMergeTask(store, rootDir, second.id);
|
||||
expect(firstResult.merged).toBe(true);
|
||||
expect(secondResult.merged).toBe(true);
|
||||
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case2A.ts`)).toContain("fn5820Case2A");
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case2B.ts`)).toContain("fn5820Case2B");
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case2A.ts")).toThrow();
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case2B.ts")).toThrow();
|
||||
|
||||
const routedEvents = store.getRunAuditEvents().filter((event) => event.mutationType === "merge:branch-group-routed");
|
||||
expect(routedEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
target: task.id,
|
||||
metadata: expect.objectContaining({
|
||||
groupId: group.id,
|
||||
mergeTargetBranch: group.branchName,
|
||||
mergeTargetSource: "branch-group-integration",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
target: second.id,
|
||||
metadata: expect.objectContaining({
|
||||
groupId: group.id,
|
||||
mergeTargetBranch: group.branchName,
|
||||
mergeTargetSource: "branch-group-integration",
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it.skipIf(!hasGit)("CASE 3: completion gate promotes exactly once after all shared members land", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5820-RI-E", settings: { testMode: true, autoMerge: true } as any });
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const second = await store.createTask({
|
||||
id: "FN-5820-RI-F",
|
||||
title: "FN-5820-RI-F",
|
||||
description: "second shared member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5820-ri-f",
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-FN5820-CASE3",
|
||||
branchName: "fusion/groups/fn-5820-promotion",
|
||||
autoMerge: true,
|
||||
});
|
||||
|
||||
await stageSharedMember(store, rootDir, { taskId: task.id, groupId: group.id, source: "planning", fileName: "fn5820Case3A" });
|
||||
await stageSharedMember(store, rootDir, { taskId: second.id, groupId: group.id, source: "planning", fileName: "fn5820Case3B" });
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.setTaskBranchGroup(second.id, group.id);
|
||||
|
||||
const firstMerge = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(firstMerge.merged).toBe(true);
|
||||
await store.updateTask(task.id, {
|
||||
column: "done",
|
||||
mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
|
||||
} as any);
|
||||
|
||||
const incomplete = await promoteBranchGroup({
|
||||
store,
|
||||
rootDir,
|
||||
groupId: group.id,
|
||||
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" } as any,
|
||||
});
|
||||
expect(incomplete.reason).toBe("incomplete");
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case3A.ts")).toThrow();
|
||||
|
||||
const secondMerge = await aiMergeTask(store, rootDir, second.id);
|
||||
expect(secondMerge.merged).toBe(true);
|
||||
await store.updateTask(second.id, {
|
||||
column: "done",
|
||||
mergeDetails: { ...(await store.getTask(second.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
|
||||
} as any);
|
||||
|
||||
const promoteWithMembers = async (recordAudit?: (event: { mutationType: string; metadata?: Record<string, unknown> }) => void) => promoteBranchGroup({
|
||||
store: {
|
||||
getBranchGroup: (...args: any[]) => (store as any).getBranchGroup(...args),
|
||||
updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args),
|
||||
listTasksByBranchGroup: async () => {
|
||||
const members = [await store.getTask(task.id), await store.getTask(second.id)].filter(Boolean) as any[];
|
||||
expect(evaluateBranchGroupCompletion({ members: members as any }).complete).toBe(true);
|
||||
return members as any;
|
||||
},
|
||||
} as any,
|
||||
rootDir,
|
||||
groupId: group.id,
|
||||
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" } as any,
|
||||
...(recordAudit ? { recordAudit } : {}),
|
||||
});
|
||||
|
||||
const audits: Array<{ mutationType: string; metadata?: Record<string, unknown> }> = [];
|
||||
const promoted = await promoteWithMembers((event) => {
|
||||
audits.push({ mutationType: event.mutationType, metadata: event.metadata });
|
||||
});
|
||||
expect(promoted.reason).toBe("promoted");
|
||||
expect(promoted.promoted).toBe(true);
|
||||
expect(store.getBranchGroup(group.id)?.status).toBe("finalized");
|
||||
expect(store.getBranchGroup(group.id)?.prState).toBe("merged");
|
||||
expect(git(rootDir, "git show main:packages/engine/src/fn5820Case3A.ts")).toContain("fn5820Case3A");
|
||||
expect(git(rootDir, "git show main:packages/engine/src/fn5820Case3B.ts")).toContain("fn5820Case3B");
|
||||
|
||||
const again = await promoteWithMembers((event) => {
|
||||
audits.push({ mutationType: event.mutationType, metadata: event.metadata });
|
||||
});
|
||||
expect(again.reason).toBe("already-finalized");
|
||||
const promotedEvents = audits.filter((event) => event.mutationType === "merge:branch-group-promoted" && event.metadata?.groupId === group.id);
|
||||
expect(promotedEvents).toHaveLength(1);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it.skipIf(!hasGit)("CASE 4: auto-merge gate disabled still integrates members into shared branch without promotion", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5820-RI-G", settings: { testMode: true, autoMerge: false } as any });
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const second = await store.createTask({
|
||||
id: "FN-5820-RI-H",
|
||||
title: "FN-5820-RI-H",
|
||||
description: "second shared member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5820-ri-h",
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-FN5820-CASE4",
|
||||
branchName: "fusion/groups/fn-5820-gated",
|
||||
autoMerge: true,
|
||||
});
|
||||
|
||||
await stageSharedMember(store, rootDir, { taskId: task.id, groupId: group.id, source: "planning", fileName: "fn5820Case4A" });
|
||||
await stageSharedMember(store, rootDir, { taskId: second.id, groupId: group.id, source: "planning", fileName: "fn5820Case4B" });
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.setTaskBranchGroup(second.id, group.id);
|
||||
|
||||
expect((await aiMergeTask(store, rootDir, task.id)).merged).toBe(true);
|
||||
expect((await aiMergeTask(store, rootDir, second.id)).merged).toBe(true);
|
||||
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case4A.ts`)).toContain("fn5820Case4A");
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5820Case4B.ts`)).toContain("fn5820Case4B");
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case4A.ts")).toThrow();
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5820Case4B.ts")).toThrow();
|
||||
|
||||
await store.updateTask(task.id, { column: "done", mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" } } as any);
|
||||
await store.updateTask(second.id, { column: "done", mergeDetails: { ...(await store.getTask(second.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" } } as any);
|
||||
|
||||
const audits: Array<{ mutationType: string; metadata?: Record<string, unknown> }> = [];
|
||||
const gated = await promoteBranchGroup({
|
||||
store: {
|
||||
getBranchGroup: (...args: any[]) => (store as any).getBranchGroup(...args),
|
||||
updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args),
|
||||
listTasksByBranchGroup: async () => [await store.getTask(task.id), await store.getTask(second.id)].filter(Boolean) as any,
|
||||
} as any,
|
||||
rootDir,
|
||||
groupId: group.id,
|
||||
settings: await store.getSettings() as any,
|
||||
recordAudit: (event) => {
|
||||
audits.push({ mutationType: event.mutationType, metadata: event.metadata });
|
||||
},
|
||||
});
|
||||
|
||||
expect(gated.reason).toBe("gated");
|
||||
expect(store.getBranchGroup(group.id)?.status).toBe("open");
|
||||
expect(audits).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mutationType: "merge:branch-group-promotion-gated",
|
||||
metadata: expect.objectContaining({
|
||||
groupId: group.id,
|
||||
branchName: group.branchName,
|
||||
effectiveEligible: false,
|
||||
reason: "settings-automerge-disabled",
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it.skipIf(!hasGit)("CASE 5: per-task-derived and ungrouped tasks remain default-branch routed", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5820-RI-I", settings: { testMode: true } as any });
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
await stageSharedMember(store, rootDir, {
|
||||
taskId: task.id,
|
||||
groupId: "BG-IGNORE",
|
||||
source: "planning",
|
||||
fileName: "fn5820Case5Ungrouped",
|
||||
});
|
||||
await store.updateTask(task.id, { branchContext: undefined } as any);
|
||||
expect((await aiMergeTask(store, rootDir, task.id)).merged).toBe(true);
|
||||
expect(git(rootDir, "git show main:packages/engine/src/fn5820Case5Ungrouped.ts")).toContain("fn5820Case5Ungrouped");
|
||||
|
||||
const derived = await store.createTask({
|
||||
id: "FN-5820-RI-J",
|
||||
title: "FN-5820-RI-J",
|
||||
description: "derived member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5820-ri-j",
|
||||
branchContext: { groupId: "BG-DERIVED", source: "planning", assignmentMode: "per-task-derived" },
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
await stageSharedMember(store, rootDir, {
|
||||
taskId: derived.id,
|
||||
groupId: "BG-DERIVED",
|
||||
source: "planning",
|
||||
fileName: "fn5820Case5Derived",
|
||||
});
|
||||
await store.updateTask(derived.id, {
|
||||
branchContext: { groupId: "BG-DERIVED", source: "planning", assignmentMode: "per-task-derived" },
|
||||
} as any);
|
||||
expect((await aiMergeTask(store, rootDir, derived.id)).merged).toBe(true);
|
||||
expect(git(rootDir, "git show main:packages/engine/src/fn5820Case5Derived.ts")).toContain("fn5820Case5Derived");
|
||||
|
||||
const routedEvents = store
|
||||
.getRunAuditEvents()
|
||||
.filter((event) => [task.id, derived.id].includes((event.target as string) ?? "") && event.mutationType === "merge:branch-group-routed");
|
||||
expect(routedEvents).toEqual([]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
Reference in New Issue
Block a user