fix(FN-branch-group): address second-round PR review feedback (#1357)

- syncGroupPrCallback forwards owner/repo to updatePr (multi-project daemons
  could 404 or edit an unrelated same-numbered PR via process-cwd fallback)
- merger background reconcile re-reads the group before persisting and skips
  the write when the PR snapshot changed (stale-write race vs newer open PR)
- branchContext.groupId trimmed on metadata emit/parse round-trip
- triageSlice non-shared invariant assertions (no groupId, no group row)
This commit is contained in:
gsxdsm
2026-06-03 15:46:23 -07:00
parent 1b83317cfe
commit 3e927bc5fc
7 changed files with 95 additions and 6 deletions

View File

@@ -194,4 +194,45 @@ describe("U6: group PR sync on member landing", () => {
await fixture.cleanup();
}
}, 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);
});

View File

@@ -7553,11 +7553,25 @@ export async function aiMergeTask(
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 !== latestGroup.prState) {
store.updateBranchGroup(latestGroup.id, {
if (reconciled.prState !== currentGroup.prState) {
store.updateBranchGroup(currentGroup.id, {
prState: reconciled.prState,
prNumber: reconciled.prNumber,
prUrl: reconciled.prUrl,