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:
7
.changeset/fn-7534-branch-group-archived-member.md
Normal file
7
.changeset/fn-7534-branch-group-archived-member.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2340,6 +2340,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
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<TaskStoreEvents> {
|
||||
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<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
|
||||
// the dashboard list route via `filterTasksByBranchGroup` so semantics can't
|
||||
// drift between the two call sites (Fix #8/#9).
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>): any {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user