FN-7534: fix branch-group completion for archived unlanded members

Branch-group completion no longer silently drops archived-but-unlanded members, which previously let genuinely-incomplete groups be flagged complete and promoted.

- listTasksByBranchGroup now scans with includeArchived:true so archived members stay counted in the group's total instead of dropping out silently
- ArchivedTaskEntry gains a persisted mergeDetails snapshot so an archived member that had already landed is still distinguished from one that never landed
- store.ts archival paths (task->archive projection) now carry mergeDetails through so isBranchGroupMemberLanded keeps working post-archival
- Added regression coverage in branch-group-store.test.ts and group-merge-coordinator.test.ts for archived-landed and archived-unlanded gating
- Added changeset documenting the fix as a patch-level bug fix

Files changed:
 .changeset/fn-7534-branch-group-archived-member.md              |   7 +
 docs/dashboard-guide.md                                         |   2 +
 packages/core/src/__tests__/branch-group-store.test.ts          |  77 +++++++++++
 packages/core/src/store.ts                                      |  30 ++++-
 packages/core/src/types.ts                                      |  11 ++
 packages/engine/src/__tests__/group-merge-coordinator.test.ts   | 148 ++++++++++++++++++++-
 6 files changed, 273 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7534
Fusion-Task-Lineage: 510af857-ce08-49a0-a2a1-41b3ad473804
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 16:07:51 -07:00
parent 31822b0336
commit df0be88482
6 changed files with 273 additions and 2 deletions

View File

@@ -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.

View File

@@ -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-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). 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 ### CLI-onboarding backfill runbook

View File

@@ -4,6 +4,7 @@ import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { TaskStore } from "../store.js"; import { TaskStore } from "../store.js";
import { isBranchGroupMemberLanded } from "../branch-group-completion.js";
function makeTmpDir(): string { function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fusion-branch-group-test-")); return mkdtempSync(join(tmpdir(), "fusion-branch-group-test-"));
@@ -363,4 +364,80 @@ describe("TaskStore branch groups", () => {
expect(archivedTask.autoMerge).toBe(true); expect(archivedTask.autoMerge).toBe(true);
expect(archivedTask.branchContext?.groupId).toBe(group.id); 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);
});
}); });

View File

@@ -2340,6 +2340,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
missionId: entry.missionId, missionId: entry.missionId,
sliceId: entry.sliceId, sliceId: entry.sliceId,
assigneeUserId: entry.assigneeUserId, 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<TaskStoreEvents> {
mergeRetries: task.mergeRetries, mergeRetries: task.mergeRetries,
error: task.error, error: task.error,
modifiedFiles: task.modifiedFiles, 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, missionId: task.missionId,
sliceId: task.sliceId, sliceId: task.sliceId,
assigneeUserId: task.assigneeUserId, assigneeUserId: task.assigneeUserId,
@@ -5638,7 +5646,27 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
} }
async listTasksByBranchGroup(groupId: string): Promise<Task[]> { async listTasksByBranchGroup(groupId: string): Promise<Task[]> {
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 // Membership filter (incl. legacy synthetic-groupId fallback) is shared with
// the dashboard list route via `filterTasksByBranchGroup` so semantics can't // the dashboard list route via `filterTasksByBranchGroup` so semantics can't
// drift between the two call sites (Fix #8/#9). // drift between the two call sites (Fix #8/#9).

View File

@@ -4994,6 +4994,17 @@ export interface ArchivedTaskEntry {
error?: string; error?: string;
/** User assigned to review this task (used during review handoff) */ /** User assigned to review this task (used during review handoff) */
assigneeUserId?: string; 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 */ /** Type of planning question presented to the user */

View File

@@ -4,7 +4,8 @@ import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; 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 { import {
evaluateBranchGroupCompletion, evaluateBranchGroupCompletion,
evaluateBranchGroupPromotion, evaluateBranchGroupPromotion,
@@ -123,6 +124,52 @@ describe("evaluateBranchGroupCompletion", () => {
expect(result.complete).toBe(false); expect(result.complete).toBe(false);
expect(result.pendingMemberIds).toEqual(["FN-A"]); 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", () => { 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)", () => { describe("promoteBranchGroup PR creation (U5)", () => {
function makeGroup(overrides?: Partial<any>): any { function makeGroup(overrides?: Partial<any>): any {
return { return {