fix(FN-branch-group): address third-round PR review feedback (#1357)
- abandon (route + CLI) preserves prState 'none' for groups that never had a PR instead of falsely persisting 'closed'; regression tests both sides - stale-snapshot write guard extracted to syncGroupPrOnLanding and covered by a fast in-memory unit test (FN-5048); the slow real-git duplicate removed
This commit is contained in:
@@ -244,9 +244,11 @@ describe("branch-group CLI abandon (agent-native parity, Fix #7)", () => {
|
||||
await runBranchGroupAbandon("BG-1");
|
||||
|
||||
expect(closeGroupPullRequestMock).not.toHaveBeenCalled();
|
||||
// A group that never had a PR keeps prState "none" — "closed" would falsely
|
||||
// imply a PR existed and was explicitly closed.
|
||||
expect(store.updateBranchGroup).toHaveBeenCalledWith(
|
||||
"BG-1",
|
||||
expect.objectContaining({ status: "abandoned", prState: "closed" }),
|
||||
expect.objectContaining({ status: "abandoned", prState: "none" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -137,7 +137,9 @@ export async function runBranchGroupAbandon(id: string, projectName?: string) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let prState: BranchGroup["prState"] = "closed";
|
||||
// A group with a PR abandons to "closed"; a group that never had a PR keeps
|
||||
// its existing prState — "closed" would falsely imply a PR existed.
|
||||
let prState: BranchGroup["prState"] = group.prNumber != null ? "closed" : group.prState;
|
||||
let prNumber = group.prNumber;
|
||||
let prUrl = group.prUrl;
|
||||
|
||||
|
||||
@@ -330,6 +330,22 @@ describe("branch group abandon (U6, R7)", () => {
|
||||
expect(closeGroupPr).not.toHaveBeenCalled();
|
||||
expect(updateBranchGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves prState 'none' when abandoning a group that never had a PR", async () => {
|
||||
// "closed" would falsely imply a PR existed and was explicitly closed.
|
||||
const noPr = { ...buildOpenGroup(), prState: "none" as const, prNumber: undefined, prUrl: undefined };
|
||||
const { store, updateBranchGroup } = buildAbandonStore(noPr);
|
||||
const closeGroupPr = vi.fn();
|
||||
const app = mount(store, closeGroupPr as unknown as ReturnType<typeof vi.fn>);
|
||||
|
||||
const res = await REQUEST(app, "POST", "/branch-groups/BG-AB/abandon", JSON.stringify({}), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(closeGroupPr).not.toHaveBeenCalled();
|
||||
expect(updateBranchGroup).toHaveBeenCalledWith(
|
||||
"BG-AB",
|
||||
expect.objectContaining({ status: "abandoned", prState: "none" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch group reconcile-on-read (Fix #3)", () => {
|
||||
|
||||
@@ -176,9 +176,11 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup
|
||||
throw badRequest("Branch group is already abandoned, finalized, or merged and cannot be abandoned");
|
||||
}
|
||||
|
||||
// The guard above already rejected `prState === "merged"`, so abandon always
|
||||
// resolves to "closed" unless the GitHub reconcile below reports otherwise.
|
||||
let prState: BranchGroup["prState"] = "closed";
|
||||
// The guard above already rejected `prState === "merged"`. A group with a PR
|
||||
// abandons to "closed" (unless the GitHub reconcile below reports otherwise);
|
||||
// a group that never had a PR keeps its existing prState — "closed" would
|
||||
// falsely imply a PR existed and was closed when none ever did.
|
||||
let prState: BranchGroup["prState"] = group.prNumber != null ? "closed" : group.prState;
|
||||
let prNumber = group.prNumber;
|
||||
let prUrl = group.prUrl;
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BranchGroup, Task } from "@fusion/core";
|
||||
import { syncGroupPrOnLanding } from "../merger.js";
|
||||
import type { SyncGroupPrFn } from "../group-merge-coordinator.js";
|
||||
|
||||
/**
|
||||
* ## Surface Enumeration
|
||||
*
|
||||
* Narrow-seam coverage (FN-5048) for the U6 sync-on-landing write guard,
|
||||
* extracted from the merger's fire-and-forget background block:
|
||||
* - no persisted open PR → the sync callback is never invoked
|
||||
* - matching snapshot + out-of-band terminal state → reconciliation persisted
|
||||
* - stale snapshot (a newer PR stored mid-sync) → the stale write is skipped
|
||||
* The full landing pipeline (real git, aiMergeTask) is covered by the
|
||||
* reliability suite `branch-group-pr-sync.test.ts`; this file pins the race
|
||||
* deterministically without expanding that slow suite.
|
||||
*/
|
||||
function makeGroup(partial: Partial<BranchGroup>): BranchGroup {
|
||||
return {
|
||||
id: "BG-1",
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-1",
|
||||
branchName: "fusion/groups/g1",
|
||||
autoMerge: true,
|
||||
prState: "open",
|
||||
prNumber: 13,
|
||||
prUrl: "https://github.com/o/r/pull/13",
|
||||
status: "open",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...partial,
|
||||
} as BranchGroup;
|
||||
}
|
||||
|
||||
function makeStore(initial: BranchGroup) {
|
||||
let group: BranchGroup = initial;
|
||||
return {
|
||||
getBranchGroup: vi.fn(() => group),
|
||||
listTasksByBranchGroup: vi.fn(async () => [] as Task[]),
|
||||
updateBranchGroup: vi.fn((_id: string, patch: Partial<BranchGroup>) => {
|
||||
group = { ...group, ...patch } as BranchGroup;
|
||||
return group;
|
||||
}),
|
||||
// test hook to simulate a concurrent landing/promotion swapping the PR
|
||||
_swap(patch: Partial<BranchGroup>) {
|
||||
group = { ...group, ...patch } as BranchGroup;
|
||||
},
|
||||
_current() {
|
||||
return group;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("syncGroupPrOnLanding (U6 stale-snapshot write guard)", () => {
|
||||
it("does not invoke the callback when the group has no persisted open PR", async () => {
|
||||
const store = makeStore(makeGroup({ prState: "none", prNumber: undefined }));
|
||||
const syncGroupPr = vi.fn() as unknown as SyncGroupPrFn;
|
||||
await syncGroupPrOnLanding({ store, groupId: "BG-1", cwd: "/tmp/project", syncGroupPr });
|
||||
expect(syncGroupPr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists out-of-band terminal reconciliation when the snapshot still matches", async () => {
|
||||
const store = makeStore(makeGroup({}));
|
||||
const syncGroupPr: SyncGroupPrFn = vi.fn(async ({ group }) => ({
|
||||
prNumber: group.prNumber!,
|
||||
prUrl: group.prUrl!,
|
||||
prState: "merged" as const,
|
||||
}));
|
||||
await syncGroupPrOnLanding({ store, groupId: "BG-1", cwd: "/tmp/project", syncGroupPr });
|
||||
expect(store.updateBranchGroup).toHaveBeenCalledTimes(1);
|
||||
expect(store._current().prState).toBe("merged");
|
||||
expect(store._current().prNumber).toBe(13);
|
||||
});
|
||||
|
||||
it("skips the stale write when a newer PR was stored between sync and write", async () => {
|
||||
const store = makeStore(makeGroup({}));
|
||||
// GitHub reports PR #13 merged out-of-band; but while the sync awaits, a
|
||||
// newer landing/promotion replaces it with a newer OPEN PR #88. The stale
|
||||
// "merged" write must be skipped so #88 survives.
|
||||
const syncGroupPr: SyncGroupPrFn = vi.fn(async ({ group }) => {
|
||||
store._swap({ prState: "open", prNumber: 88, prUrl: "https://github.com/o/r/pull/88" });
|
||||
return { prNumber: group.prNumber!, prUrl: group.prUrl!, prState: "merged" as const };
|
||||
});
|
||||
await syncGroupPrOnLanding({ store, groupId: "BG-1", cwd: "/tmp/project", syncGroupPr });
|
||||
expect(store.updateBranchGroup).not.toHaveBeenCalled();
|
||||
expect(store._current().prNumber).toBe(88);
|
||||
expect(store._current().prState).toBe("open");
|
||||
});
|
||||
});
|
||||
@@ -195,44 +195,4 @@ describe("U6: group PR sync on member landing", () => {
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it.skipIf(!hasGit)("does not clobber a newer PR stored between sync and write (stale snapshot guard)", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-U6-SYNC-STALE", settings: { testMode: true, autoMerge: true } as any });
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-U6-STALE",
|
||||
branchName: "fusion/groups/fn-u6-stale",
|
||||
autoMerge: true,
|
||||
});
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.updateTask(task.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any);
|
||||
// Snapshot synced by this background task: open PR #13.
|
||||
store.updateBranchGroup(group.id, { prState: "open", prNumber: 13, prUrl: "https://github.com/o/r/pull/13" });
|
||||
|
||||
// GitHub reports PR #13 merged out-of-band; but while we await, a newer
|
||||
// landing/promotion replaces it with a newer OPEN PR #88. The stale write
|
||||
// (which would mark the group merged) must be skipped so #88 survives.
|
||||
const syncGroupPr: SyncGroupPrFn = vi.fn(async ({ group: g }) => {
|
||||
store.updateBranchGroup(group.id, { prState: "open", prNumber: 88, prUrl: "https://github.com/o/r/pull/88" });
|
||||
return { prNumber: g.prNumber!, prUrl: g.prUrl!, prState: "merged" as const };
|
||||
});
|
||||
|
||||
let syncSettled: Promise<void> = Promise.resolve();
|
||||
await stageMergeBranch(store, rootDir, task.id, "fnU6Stale");
|
||||
const merge = await aiMergeTask(store, rootDir, task.id, {
|
||||
syncGroupPr,
|
||||
onGroupPrSyncSettled: (settled) => {
|
||||
syncSettled = settled;
|
||||
},
|
||||
});
|
||||
expect(merge.merged).toBe(true);
|
||||
await syncSettled;
|
||||
// The newer open PR #88 is untouched; the stale "merged" reconciliation was skipped.
|
||||
expect(store.getBranchGroup(group.id)?.prNumber).toBe(88);
|
||||
expect(store.getBranchGroup(group.id)?.prState).toBe("open");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
|
||||
@@ -7440,6 +7440,54 @@ async function tryEarlyEmptyOwnDiffFinalize(input: {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* U6 (R6) sync-on-landing seam, extracted for narrow unit testing (FN-5048: the
|
||||
* stale-snapshot write guard is covered in-memory, not via the slow real-git
|
||||
* reliability suite). Pushes the group PR body for a group with a persisted
|
||||
* open PR, then persists out-of-band reconciliation — but only when the group
|
||||
* still points at the exact PR snapshot that was synced (same prNumber AND
|
||||
* prState). A newer landing/promotion that swapped in a different PR mid-sync
|
||||
* must not be clobbered by this stale write.
|
||||
*/
|
||||
export async function syncGroupPrOnLanding(input: {
|
||||
store: Pick<TaskStore, "getBranchGroup" | "listTasksByBranchGroup" | "updateBranchGroup">;
|
||||
groupId: string;
|
||||
cwd: string;
|
||||
syncGroupPr: import("./group-merge-coordinator.js").SyncGroupPrFn;
|
||||
}): Promise<void> {
|
||||
const { store, groupId, cwd, syncGroupPr } = input;
|
||||
const latestGroup = store.getBranchGroup(groupId);
|
||||
if (!latestGroup || latestGroup.prNumber == null || latestGroup.prState !== "open") {
|
||||
return;
|
||||
}
|
||||
const members = await store.listTasksByBranchGroup(latestGroup.id);
|
||||
const reconciled = await syncGroupPr({
|
||||
cwd,
|
||||
group: latestGroup,
|
||||
members,
|
||||
});
|
||||
// Guard against stale snapshots: a newer landing/promotion may have stored a
|
||||
// different (e.g. newer open) PR for this group while we were awaiting the
|
||||
// sync. Re-read and only persist when the snapshot still matches.
|
||||
const currentGroup = store.getBranchGroup(groupId);
|
||||
if (
|
||||
!currentGroup ||
|
||||
currentGroup.prNumber !== latestGroup.prNumber ||
|
||||
currentGroup.prState !== latestGroup.prState
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Out-of-band reconciliation: if GitHub reports the PR is no longer open
|
||||
// (closed/merged), persist the corrected prState rather than leaving a stale "open".
|
||||
if (reconciled.prState !== currentGroup.prState) {
|
||||
store.updateBranchGroup(currentGroup.id, {
|
||||
prState: reconciled.prState,
|
||||
prNumber: reconciled.prNumber,
|
||||
prUrl: reconciled.prUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function aiMergeTask(
|
||||
store: TaskStore,
|
||||
rootDir: string,
|
||||
@@ -7542,42 +7590,12 @@ export async function aiMergeTask(
|
||||
if (options.syncGroupPr) {
|
||||
const syncGroupPr = options.syncGroupPr;
|
||||
const groupId = groupRouting.branchGroup.id;
|
||||
const settled = (async () => {
|
||||
const latestGroup = store.getBranchGroup(groupId);
|
||||
if (!latestGroup || latestGroup.prNumber == null || latestGroup.prState !== "open") {
|
||||
return;
|
||||
}
|
||||
const members = await store.listTasksByBranchGroup(latestGroup.id);
|
||||
const reconciled = await syncGroupPr({
|
||||
cwd: projectRootDir,
|
||||
group: latestGroup,
|
||||
members,
|
||||
});
|
||||
// Guard against stale snapshots: a newer landing/promotion may have
|
||||
// stored a different (e.g. newer open) PR for this group while we were
|
||||
// awaiting the sync. Re-read the current group and only persist the
|
||||
// reconciled state if it still points at the exact PR snapshot we
|
||||
// synced (same prNumber AND prState); otherwise skip to avoid clobbering
|
||||
// the newer PR.
|
||||
const currentGroup = store.getBranchGroup(groupId);
|
||||
if (
|
||||
!currentGroup ||
|
||||
currentGroup.prNumber !== latestGroup.prNumber ||
|
||||
currentGroup.prState !== latestGroup.prState
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Out-of-band reconciliation: if GitHub reports the PR is no longer
|
||||
// open (closed/merged), persist the corrected prState rather than
|
||||
// leaving a stale "open".
|
||||
if (reconciled.prState !== currentGroup.prState) {
|
||||
store.updateBranchGroup(currentGroup.id, {
|
||||
prState: reconciled.prState,
|
||||
prNumber: reconciled.prNumber,
|
||||
prUrl: reconciled.prUrl,
|
||||
});
|
||||
}
|
||||
})().catch((err) => {
|
||||
const settled = syncGroupPrOnLanding({
|
||||
store,
|
||||
groupId,
|
||||
cwd: projectRootDir,
|
||||
syncGroupPr,
|
||||
}).catch((err) => {
|
||||
// Non-fatal: never fail the merge/landing because PR sync failed.
|
||||
try {
|
||||
store.recordRunAuditEvent({
|
||||
|
||||
Reference in New Issue
Block a user