diff --git a/.changeset/fn-7534-branch-group-archived-member.md b/.changeset/fn-7534-branch-group-archived-member.md new file mode 100644 index 0000000000..d79c918c9d --- /dev/null +++ b/.changeset/fn-7534-branch-group-archived-member.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Branch groups no longer report complete (or become promotable) when an unlanded member is archived. +category: fix +dev: listTasksByBranchGroup membership now scans with includeArchived:true so an archived-but-unlanded member stays counted in total instead of silently dropping out; mergeDetails is now persisted on ArchivedTaskEntry so an archived member that had already landed keeps counting as landed. evaluateBranchGroupCompletion / promoteBranchGroup gate correctly; merge-target-safety in isBranchGroupMemberLanded is unchanged. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index eeabb23324..0cdeef4f30 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1827,6 +1827,8 @@ The Group Task Modal shows shared branch name/status, member list (`taskId`, tit > **FN-7532:** a member only counts as "landed" once it merge-confirms onto its OWN group's branch via the branch-group-integration path (`mergeDetails.mergeTargetSource === "branch-group-integration"` and a matching `mergeTargetBranch`) — this is the same predicate the engine's promotion gate uses, so the checklist can never show "complete" when a real promotion would still be refused (or vice versa). The merge engine now stamps this attribution for every merge (previously only the legacy merge path did, so shared-group members merged through the current path were undercounted). +> **FN-7534:** archiving a member does NOT remove it from its branch group's completion count. An archived member that never landed stays in `total` as pending, so the checklist and the engine promotion gate (`promoteBranchGroup`) both keep reporting the group incomplete — archiving a stuck/abandoned member is not a way to force a group to "complete". An archived member that HAD already landed before archival keeps counting as landed (its merge-confirmation is frozen at archive time), so a group that was genuinely done before one of its members got archived does not regress into a permanent stuck state. + Both the modal and branch-group card are completion-gated: while members are still pending, they show progress only. PR / merge controls are only revealed after all members are landed into the shared branch. When auto-merge is off, promote/open-PR is explicit user action (no automatic push-to-origin behavior). ### CLI-onboarding backfill runbook diff --git a/packages/core/src/__tests__/branch-group-store.test.ts b/packages/core/src/__tests__/branch-group-store.test.ts index 3a00606529..37cfd2bb85 100644 --- a/packages/core/src/__tests__/branch-group-store.test.ts +++ b/packages/core/src/__tests__/branch-group-store.test.ts @@ -4,6 +4,7 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { TaskStore } from "../store.js"; +import { isBranchGroupMemberLanded } from "../branch-group-completion.js"; function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "fusion-branch-group-test-")); @@ -363,4 +364,80 @@ describe("TaskStore branch groups", () => { expect(archivedTask.autoMerge).toBe(true); expect(archivedTask.branchContext?.groupId).toBe(group.id); }); + + /* + * FNXC:BranchGroupCompletion 2026-07-04-00:00: + * FN-7534: an unlanded member archived while still belonging to a branch group must + * NEVER silently drop out of `listTasksByBranchGroup`'s membership set — it previously + * vanished from `total` without any corresponding drop in `landed`, letting + * `isBranchGroupComplete`/`evaluateBranchGroupCompletion` flip a genuinely-incomplete + * group to `complete: true`. The fix scans with `includeArchived: true` so archived + * members stay counted, and `mergeDetails` is now persisted on the archive entry (it + * was previously dropped at the archive boundary) so an archived member that HAD landed + * before archival keeps counting as landed rather than regressing to "pending" forever. + */ + it("keeps an archived unlanded member in branch-group membership so completion cannot go true prematurely (FN-7534)", async () => { + const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-archived-unlanded", branchName: "fn/archived-unlanded" }); + + const landedTask = await store.createTask({ description: "landed member" }); + await store.setTaskBranchGroup(landedTask.id, group.id); + await store.updateTask(landedTask.id, { + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: group.branchName, + } as any, + }); + + const abandonedTask = await store.createTask({ description: "unlanded member, later archived" }); + await store.setTaskBranchGroup(abandonedTask.id, group.id); + await store.archiveTask(abandonedTask.id); + + const members = await store.listTasksByBranchGroup(group.id); + expect(members.map((task) => task.id).sort()).toEqual([abandonedTask.id, landedTask.id].sort()); + + const archivedMember = members.find((task) => task.id === abandonedTask.id)!; + expect(archivedMember.column).toBe("archived"); + expect(isBranchGroupMemberLanded(archivedMember, group)).toBe(false); + + const landedMember = members.find((task) => task.id === landedTask.id)!; + expect(isBranchGroupMemberLanded(landedMember, group)).toBe(true); + }); + + it("preserves mergeDetails on an archived member that had already landed before archival (FN-7534)", async () => { + const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-archived-landed", branchName: "fn/archived-landed" }); + + const task = await store.createTask({ description: "landed then archived" }); + await store.setTaskBranchGroup(task.id, group.id); + await store.updateTask(task.id, { + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: group.branchName, + } as any, + }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "done"); + await store.archiveTask(task.id); + + const members = await store.listTasksByBranchGroup(group.id); + expect(members).toHaveLength(1); + expect(members[0].column).toBe("archived"); + expect(isBranchGroupMemberLanded(members[0], group)).toBe(true); + }); + + it("still matches a legacy synthetic-groupId member into membership after it is archived (FN-7534)", async () => { + const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-legacy-archived", branchName: "fn/legacy-archived" }); + const legacyTask = await store.createTask({ + description: "legacy member, later archived", + branchContext: { groupId: "planning:PS-legacy-archived", source: "planning", assignmentMode: "shared" }, + }); + + await store.archiveTask(legacyTask.id); + + const members = await store.listTasksByBranchGroup(group.id); + expect(members.map((task) => task.id)).toEqual([legacyTask.id]); + expect(isBranchGroupMemberLanded(members[0], group)).toBe(false); + }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 50ffeff040..8d69a5e26c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -2340,6 +2340,10 @@ export class TaskStore extends EventEmitter { missionId: entry.missionId, sliceId: entry.sliceId, assigneeUserId: entry.assigneeUserId, + // FNXC:BranchGroupCompletion 2026-07-04-00:00: FN-7534 — restore the frozen + // mergeDetails snapshot so isBranchGroupMemberLanded can still tell an + // archived-but-landed member apart from one that never landed. + mergeDetails: entry.mergeDetails, }; } @@ -2482,6 +2486,10 @@ export class TaskStore extends EventEmitter { mergeRetries: task.mergeRetries, error: task.error, modifiedFiles: task.modifiedFiles, + // FNXC:BranchGroupCompletion 2026-07-04-00:00: FN-7534 — persist mergeDetails on + // archival so an archived member that had already landed against its branch group + // keeps counting as landed (see ArchivedTaskEntry.mergeDetails doc comment). + mergeDetails: task.mergeDetails, missionId: task.missionId, sliceId: task.sliceId, assigneeUserId: task.assigneeUserId, @@ -5638,7 +5646,27 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } async listTasksByBranchGroup(groupId: string): Promise { - const tasks = await this.listTasks({ includeArchived: false, slim: true }); + /* + * FNXC:BranchGroupCompletion 2026-07-04-00:00: + * FN-7534: this scan previously used `includeArchived: false`, which silently + * dropped an archived-but-UNLANDED member from `total` with no corresponding + * drop from `landed` — `isBranchGroupComplete` / `evaluateBranchGroupCompletion` + * ("total>0 && every member landed") could then flip a genuinely-incomplete + * group to `complete: true`, making the engine promotion gate + * (`promoteBranchGroup`) eligible to promote unfinished work. Scanning with + * `includeArchived: true` keeps every member — archived or not — in the + * membership set; `isBranchGroupMemberLanded` (merge-target-safety unchanged) + * still governs whether each one counts as landed. An archived member that had + * already landed before archival is now told apart from one that never landed + * via the mergeDetails snapshot persisted on the archive entry (see + * ArchivedTaskEntry.mergeDetails), so this does not regress a + * previously-complete group into a permanent deadlock. This is the SOLE + * membership scan shared by the dashboard branch-groups route, the CLI + * `branch-group` command, and the engine group-merge coordinator + * (`promoteBranchGroup`) — fixing it here means all three inherit the + * correction without further changes. + */ + const tasks = await this.listTasks({ includeArchived: true, slim: true }); // Membership filter (incl. legacy synthetic-groupId fallback) is shared with // the dashboard list route via `filterTasksByBranchGroup` so semantics can't // drift between the two call sites (Fix #8/#9). diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6152b03b28..eebd609c89 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4994,6 +4994,17 @@ export interface ArchivedTaskEntry { error?: string; /** User assigned to review this task (used during review handoff) */ assigneeUserId?: string; + /** + * FNXC:BranchGroupCompletion 2026-07-04-00:00: + * FN-7534: frozen merge-confirmation snapshot, captured at archive time. Previously + * dropped entirely on archival, which meant a branch-group member that had already + * landed before being archived could never be told apart from one that never landed — + * both looked identical (mergeDetails undefined) to isBranchGroupMemberLanded once + * archived. Persisting it here lets an archived-but-already-landed member keep + * counting as landed for branch-group completion instead of regressing to "pending" + * and permanently deadlocking an otherwise-complete group. + */ + mergeDetails?: MergeDetails; } /** Type of planning question presented to the user */ diff --git a/packages/engine/src/__tests__/group-merge-coordinator.test.ts b/packages/engine/src/__tests__/group-merge-coordinator.test.ts index e0ee11766c..4cc30c1ddc 100644 --- a/packages/engine/src/__tests__/group-merge-coordinator.test.ts +++ b/packages/engine/src/__tests__/group-merge-coordinator.test.ts @@ -4,7 +4,8 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { describe, expect, it, afterEach } from "vitest"; +import { describe, expect, it, afterEach, beforeEach } from "vitest"; +import { TaskStore } from "@fusion/core"; import { evaluateBranchGroupCompletion, evaluateBranchGroupPromotion, @@ -123,6 +124,52 @@ describe("evaluateBranchGroupCompletion", () => { expect(result.complete).toBe(false); expect(result.pendingMemberIds).toEqual(["FN-A"]); }); + + /* + * FNXC:BranchGroupCompletion 2026-07-04-00:00: + * FN-7534: an archived member that never landed onto the group branch must still count + * as pending — it must NOT silently drop out of the membership set (see + * TaskStore.listTasksByBranchGroup, which now scans with includeArchived: true so this + * shape of member reaches the coordinator at all). + */ + it("does NOT count an archived member that never landed onto the group branch (FN-7534)", () => { + const result = evaluateBranchGroupCompletion({ + members: [ + landed("FN-A"), + { id: "FN-B", column: "archived" as const } as any, + ] as any, + group, + }); + + expect(result.complete).toBe(false); + expect(result.landedMemberIds).toEqual(["FN-A"]); + expect(result.pendingMemberIds).toEqual(["FN-B"]); + }); + + it("still counts an archived member as landed once its mergeDetails snapshot is preserved (FN-7534)", () => { + const result = evaluateBranchGroupCompletion({ + members: [ + landed("FN-A"), + { + id: "FN-B", + column: "archived" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + } as any, + } as any, + ] as any, + group, + }); + + expect(result).toEqual({ + complete: true, + totalMembers: 2, + landedMemberIds: ["FN-A", "FN-B"], + pendingMemberIds: [], + }); + }); }); describe("evaluateBranchGroupPromotion", () => { @@ -338,6 +385,105 @@ describe("promoteBranchGroup", () => { }); }); +/* + * FNXC:BranchGroupCompletion 2026-07-04-00:00: + * FN-7534: the completion gate is a first-class regression target, not just the display + * serializers (FN-5893). These tests wire a REAL TaskStore (not a hand-rolled fixture) so + * `promoteBranchGroup` exercises the actual `listTasksByBranchGroup` membership scan that + * previously silently dropped an archived-but-unlanded member from `total`. + */ +describe("promoteBranchGroup with a real TaskStore (FN-7534 archived-member regression)", () => { + function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "fusion-branch-group-archive-")); + } + + let rootDir: string; + let storeRootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeRepo(); + storeRootDir = makeTmpDir(); + globalDir = join(storeRootDir, ".fusion-global"); + store = new TaskStore(storeRootDir, globalDir); + await store.init(); + }); + + afterEach(() => { + store.close(); + }); + + it("returns reason: incomplete when an archived member never landed onto the group branch", async () => { + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-archived-gate", + branchName: "fusion/groups/archived-gate", + }); + + const landedTask = await store.createTask({ description: "landed member" }); + await store.setTaskBranchGroup(landedTask.id, group.id); + await store.updateTask(landedTask.id, { + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: group.branchName, + } as any, + }); + + const abandonedTask = await store.createTask({ description: "unlanded member, later archived" }); + await store.setTaskBranchGroup(abandonedTask.id, group.id); + await store.archiveTask(abandonedTask.id); + + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" }, + store, + }); + + expect(result.reason).toBe("incomplete"); + expect(result.promoted).toBe(false); + }); + + it("still promotes when the only archived member had already landed before archival", async () => { + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-archived-landed-gate", + branchName: "fusion/groups/archived-landed-gate", + autoMerge: true, + }); + execSync(`git checkout -b ${group.branchName}`, { cwd: rootDir }); + execSync("echo promoted > group.txt", { cwd: rootDir, shell: "/bin/bash" }); + execSync("git add group.txt && git commit -m group", { cwd: rootDir, shell: "/bin/bash" }); + execSync("git checkout main", { cwd: rootDir }); + + const task = await store.createTask({ description: "landed then archived" }); + await store.setTaskBranchGroup(task.id, group.id); + await store.updateTask(task.id, { + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: group.branchName, + } as any, + }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "done"); + await store.archiveTask(task.id); + + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" }, + store, + }); + + expect(result.reason).toBe("promoted"); + expect(result.promoted).toBe(true); + }); +}); + describe("promoteBranchGroup PR creation (U5)", () => { function makeGroup(overrides?: Partial): any { return {