diff --git a/.changeset/fix-branch-group-name-collision-triage.md b/.changeset/fix-branch-group-name-collision-triage.md new file mode 100644 index 0000000000..2c503d15bc --- /dev/null +++ b/.changeset/fix-branch-group-name-collision-triage.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": patch +--- + +Fix mission triage silently stranding features when two missions share a base branch. + +`branch_groups.branchName` is globally unique, but `ensureBranchGroupForSource` only checked for an existing group by `(sourceType, sourceId)`. When a second mission's shared-branch triage resolved to a base branch (e.g. `main`) that another mission already owned a branch group for, `createBranchGroup` threw `UNIQUE constraint failed: branch_groups.branchName`. That error escaped `triageFeature` and was swallowed by both of its callers (the validation-failure auto-triage and the startup/maintenance reconcile sweep), leaving the mission's `defined` features — including auto-generated fix features — permanently un-triaged and the mission unable to progress. + +`ensureBranchGroupForSource` now reuses an existing open group for the same branch name (matching the established `getBranchGroupByBranchName(...) ?? ensureBranchGroupForSource(...)` idiom) instead of colliding on the unique constraint. diff --git a/packages/core/src/__tests__/branch-group-store.test.ts b/packages/core/src/__tests__/branch-group-store.test.ts index ae26efd00d..17d641af37 100644 --- a/packages/core/src/__tests__/branch-group-store.test.ts +++ b/packages/core/src/__tests__/branch-group-store.test.ts @@ -67,6 +67,34 @@ describe("TaskStore branch groups", () => { expect(second.autoMerge).toBe(true); }); + it("reuses an existing open group with the same branchName across sources instead of throwing", () => { + // Regression: branch_groups.branchName is globally UNIQUE. When one mission + // already owns an open group for a shared base branch, a second source whose + // triage resolves to the same branch must reuse that group rather than crash + // on the UNIQUE constraint. (Mission triage discards the result and only needs + // it not to throw; a thrown error there silently strands "defined" features.) + const owner = store.createBranchGroup({ sourceType: "mission", sourceId: "M-OWNER", branchName: "main" }); + + let reusedByMission!: ReturnType; + expect(() => { + reusedByMission = store.ensureBranchGroupForSource("mission", "M-OTHER", { + branchName: "main", + autoMerge: true, + }); + }).not.toThrow(); + expect(reusedByMission.id).toBe(owner.id); + + // Invariant holds across the other source types that share this helper. + const reusedByNewTask = store.ensureBranchGroupForSource("new-task", "shared/main", { branchName: "main" }); + expect(reusedByNewTask.id).toBe(owner.id); + + const reusedByPlanning = store.ensureBranchGroupForSource("planning", "PS-main", { branchName: "main" }); + expect(reusedByPlanning.id).toBe(owner.id); + + // No duplicate rows were created for the shared branch. + expect(store.listBranchGroups().filter((g) => g.branchName === "main")).toHaveLength(1); + }); + it("supports new-task branch group sources and round-trips through lookups", () => { const group = store.ensureBranchGroupForSource("new-task", "shared/onboarding", { branchName: "shared/onboarding", diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d5ff59dcb9..073a86b2d6 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -4385,6 +4385,17 @@ export class TaskStore extends EventEmitter { return existing; } + // `branch_groups.branchName` is globally UNIQUE — a branch is represented by + // exactly one open group. If another source already owns an open group for + // this branch, reuse it rather than calling createBranchGroup and violating + // the UNIQUE constraint. Without this, two missions whose shared base resolves + // to the same branch (e.g. "main") collide: the throw escapes triageFeature + // and is swallowed by its callers, silently stranding "defined" features. + const existingByBranch = this.getBranchGroupByBranchName(init.branchName); + if (existingByBranch) { + return existingByBranch; + } + return this.createBranchGroup({ sourceType, sourceId,