diff --git a/.changeset/fn-7438-branch-group-restart-recovery.md b/.changeset/fn-7438-branch-group-restart-recovery.md new file mode 100644 index 0000000000..9c29efd072 --- /dev/null +++ b/.changeset/fn-7438-branch-group-restart-recovery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Recover stale task branch-group references from Task Detail after server restarts. +category: fix +dev: Adds branch_group restart regression coverage and non-origin integration-branch diagnostics. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 71c4027b0c..92051a2ff0 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1740,7 +1740,7 @@ The dashboard now exposes branch-group visibility and controls for shared planni - `GET /api/branch-groups` lists groups with completion (`landed`/`total`) and tracked PR metadata. - `GET /api/branch-groups/:id` returns group details (shared branch, members, per-member landed state, completion, PR state). -- `POST /api/branch-groups/assign` is the supported online grouping path to attach/detach tasks (`{ taskId, groupId|null, branchName? }`). +- `POST /api/branch-groups/assign` is the supported online grouping path to attach/detach tasks (`{ taskId, groupId|null, branchName? }`). Passing `groupId: null` clears only that task's branch-group context and preserves unrelated task source metadata. - `POST /api/branch-groups/:id/promote` triggers the engine promotion flow (`promoteBranchGroup`) and returns promotion/PR status. UI surfaces: @@ -1749,9 +1749,10 @@ UI surfaces: - Task cards show grouped/shared branch metadata for grouped tasks. - Clicking either grouped badge opens the dedicated **Group Task Modal** for that branch group. - Task detail renders a branch-group card with member landed progress. +- If a task references a stale/missing branch group, Task Detail shows a **Stale branch group reference** recovery message with **Reset branch group for this task**. The action uses the supported assign API to clear only the current task's context, then reloads the detail so the card disappears and the task can proceed ungrouped without raw SQLite surgery. - In Task Detail Logs on mobile, the branch-group card includes a collapse/expand toggle so logs can reclaim vertical space while keeping group summary progress visible. -The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events. +The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. Branch groups are durable SQLite state keyed by real `BG-*` ids, so valid grouped tasks continue to list/show after a server restart. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events. 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). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index fe72e61c4a..043145facb 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -411,7 +411,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF | `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. | | `mergeIntegrationWorktree` | `"reuse-task-worktree" \| "cwd-integration-branch" \| "cwd-main"` | `"reuse-task-worktree"` | Auto-merge integration-root mode for direct merges only (`mergeStrategy="direct"`). `reuse-task-worktree` (default) runs the rebase/conflict/audit/finalize cascade inside the task worktree after FN-5279 reuse-handoff gates, leaving project-root `HEAD`/dirty state untouched. `cwd-integration-branch` is an explicit operator opt-in escape hatch that runs the cascade from the resolved integration branch in the project-root worktree and surfaces an operator-visible startup warning per FN-5348. `cwd-main` is a deprecated legacy alias: `normalizeMergeIntegrationWorktreeMode(...)` normalizes it to `cwd-integration-branch` at read time and emits a one-shot `[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch` warning; new configs must not use it. When `worktrunk.enabled=true`, worktrunk-managed merge/worktree handling takes precedence and this setting is advisory until the native path runs. Reuse-handoff refusal must never silently fall back to `cwd-integration-branch`: any future fallback path must emit `merge:cwd-integration-fallback-removed`, and current behavior leaves the task in `in-review` instead. | | `mergeAdvanceAutoSync` | `"off" \| "ff-only" \| "stash-and-ff"` | `"stash-and-ff"` | After the merger advances the integration-branch ref, what to do in **other** worktrees still on that branch (typically your project-root checkout). `off` leaves them alone; users must `git pull` or click the Merge Advance Notice banner's Pull button to bring their checkout forward — this is the surprise behavior that made `git status` look like the merge had been reverted. `ff-only` auto-fast-forwards only when the other worktree's index and working tree are clean; dirty worktrees stay untouched and the banner still surfaces for manual pull. `stash-and-ff` (default) runs the Smart Pull pipeline (stash → fast-forward → pop) so local edits survive across the auto-sync. Pop conflicts emit `merge:auto-sync` audit events with `outcome: "stash-pop-conflict"` and surface through the dashboard's existing stash-conflict modal. Only applies to direct merges. | -| `integrationBranch` | `string` | `undefined` | Optional canonical project integration branch override. Resolution order for merge/self-healing/branch-conflict defaults is `integrationBranch` → legacy `baseBranch` → `origin/HEAD` symbolic ref → fallback `"main"`. This resolved value is used as `projectDefaultBranch` for `resolveTaskMergeTarget(...)`; task-level overrides still come from task metadata. | +| `integrationBranch` | `string` | `undefined` | Optional canonical project integration branch override. Resolution order for merge/self-healing/branch-conflict defaults is `integrationBranch` → legacy `baseBranch` → `origin/HEAD` symbolic ref → fallback `"main"`. Auto-detect intentionally checks `origin/HEAD` only; if a repository uses another remote (for example `gitlab`) and has no `origin` alias or `origin/HEAD`, Fusion logs a one-time warning naming discovered remotes and still falls back to `main`. Add an `origin` alias / `origin/HEAD` or set `integrationBranch` explicitly for non-`origin` remotes. This resolved value is used as `projectDefaultBranch` for `resolveTaskMergeTarget(...)`; task-level overrides still come from task metadata. | | `prerebaseAutoEnabled` | `boolean` | `true` | Master switch for pre-merge auto-prerebase policy. When enabled, merger checks divergence from `` to local `main` and may rebase before Stage 1/2 rebases. Ignored when `worktrunk.enabled=true` (worktrunk-managed path defers this layer). | | `prerebaseHotFiles` | `string[]` | `[`"AGENTS.md"`, `"packages/core/src/store.ts"`, `"packages/core/src/db.ts"`, `"packages/engine/src/executor.ts"`, `"packages/engine/src/scheduler.ts"`, `"packages/engine/src/merger.ts"`, `"packages/dashboard/app/styles.css"`]` | Exact-path trigger list for auto-prerebase. If any listed file appears in `..localMainHead`, merger runs prerebase first, then continues through the existing Stage 1/2 cascade. Empty array disables hot-file triggering. | | `prerebaseDivergenceThreshold` | `number` | `50` | Commit-count trigger for auto-prerebase. When `..localMainHead` commit count is greater than this value, prerebase fires even without hot-file overlap. Set `0` (or unset) to disable threshold triggering. | diff --git a/docs/storage.md b/docs/storage.md index a7db2a49f3..6be057213a 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -404,7 +404,7 @@ Database Backup automation failures are surfaced with DB-qualified detail. Proje | Table | Purpose | |---|---| | `tasks` | Core task metadata and JSON-backed nested fields (priority, dependencies, steps, log, attachments, comments, model overrides, workflow results, merge details, assignment, mission linkage). | -| `branch_groups` | Durable shared-branch group records keyed by `BG-*` id with source linkage (`mission`/`planning`), branch/worktree metadata, optional PR tracking fields, lifecycle status, and per-group `autoMerge` override. | +| `branch_groups` | Durable shared-branch group records keyed by `BG-*` id with source linkage (`mission`/`planning`/`new-task`), branch/worktree metadata, optional PR tracking fields, lifecycle status, and per-group `autoMerge` override. This SQLite row is the authority for grouped-task reads after restart; task `sourceMetadata.fusionBranchContext.groupId` values should point at real `BG-*` rows, and stale per-task references are cleared through `TaskStore.setTaskBranchGroup(taskId, null)` / `POST /api/branch-groups/assign` rather than raw SQLite edits. | | `mergeQueue` | Durable merge handoff queue keyed by `taskId`. Stores enqueue ordering (`enqueuedAt`, mirrored `priority`), single-owner lease state (`leasedBy`, `leasedAt`, `leaseExpiresAt`), and retry diagnostics (`attemptCount`, `lastError`). Leasing is priority-first + FIFO within priority, and expired leases are recoverable without incrementing attempts. FN-5242 adds the persistence/lease primitive; FN-5241 and FN-5243 wire executor enqueue + merger consumption. | FN-5240/FN-5241/FN-5242 establish the handoff invariant: the only legal executor/self-healing path into `in-review` after execution finishes is `TaskStore.handoffToReview(...)`. That helper runs the column move, `mergeQueue` insert, and handoff audit fan-out inside one `BEGIN IMMEDIATE` transaction so observers never see `column = "in-review"` without the matching queue row. Direct `moveTask(taskId, "in-review")` writes remain allowed for explicit non-handoff/test paths but emit `task:handoff-invariant-violation` run-audit events unless the caller opts into the narrow allowlist flag. diff --git a/packages/core/src/__tests__/branch-group-store.test.ts b/packages/core/src/__tests__/branch-group-store.test.ts index 74c8405b16..3a00606529 100644 --- a/packages/core/src/__tests__/branch-group-store.test.ts +++ b/packages/core/src/__tests__/branch-group-store.test.ts @@ -105,6 +105,42 @@ describe("TaskStore branch groups", () => { expect(store.getBranchGroup(group.id)?.sourceType).toBe("new-task"); }); + it("FN-7438: reloads durable branch groups and member tasks after store restart", async () => { + const group = store.ensureBranchGroupForSource("planning", "PS-restart", { + branchName: "feature/restart-shared", + autoMerge: true, + }); + const memberA = await store.createTask({ + description: "restart member a", + branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, + }); + const memberB = await store.createTask({ + description: "restart member b", + branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, + }); + await store.createTask({ description: "ungrouped after restart" }); + + store.close(); + store = new TaskStore(rootDir, globalDir); + await store.init(); + + expect(store.listBranchGroups().map((entry) => entry.id)).toContain(group.id); + expect(store.listBranchGroups({ status: "open" }).map((entry) => entry.id)).toContain(group.id); + expect(store.getBranchGroup(group.id)).toEqual(expect.objectContaining({ + id: group.id, + branchName: "feature/restart-shared", + sourceType: "planning", + sourceId: "PS-restart", + autoMerge: true, + })); + + const members = await store.listTasksByBranchGroup(group.id); + expect(members.map((task) => task.id).sort()).toEqual([memberA.id, memberB.id].sort()); + expect((await store.getTask(memberA.id)).sourceMetadata).toEqual({ + fusionBranchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, + }); + }); + it("enforces unique branchName", () => { store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" }); expect(() => @@ -173,7 +209,10 @@ describe("TaskStore branch groups", () => { }); it("sets and clears task branchContext via setTaskBranchGroup", async () => { - const task = await store.createTask({ description: "branch link test" }); + const task = await store.createTask({ + description: "branch link test", + source: { sourceType: "api", sourceMetadata: { externalKey: "keep-me" } }, + }); const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-1", branchName: "fn/planning" }); const onUpdated = vi.fn(); @@ -182,10 +221,12 @@ describe("TaskStore branch groups", () => { await store.setTaskBranchGroup(task.id, group.id); const linked = await store.getTask(task.id); expect(linked.branchContext).toEqual({ groupId: group.id, source: "planning", assignmentMode: "shared" }); + expect(linked.sourceMetadata).toEqual(expect.objectContaining({ externalKey: "keep-me" })); await store.setTaskBranchGroup(task.id, null); const cleared = await store.getTask(task.id); expect(cleared.branchContext).toBeUndefined(); + expect(cleared.sourceMetadata).toEqual({ externalKey: "keep-me" }); expect(onUpdated).toHaveBeenCalled(); await expect(store.setTaskBranchGroup(task.id, "BG-missing")).rejects.toThrow("not found"); diff --git a/packages/dashboard/app/components/BranchGroupCard.css b/packages/dashboard/app/components/BranchGroupCard.css index 3a8caf3bc2..cad383a2b3 100644 --- a/packages/dashboard/app/components/BranchGroupCard.css +++ b/packages/dashboard/app/components/BranchGroupCard.css @@ -17,6 +17,15 @@ Collapsed branch-group details must consume less task-detail space with token-ba color: var(--color-error); } +.branch-group-card-error-title { + font-weight: var(--font-weight-semibold); +} + +.branch-group-card-error-copy { + margin: 0; + color: var(--text-muted); +} + .branch-group-card-header { display: flex; align-items: center; diff --git a/packages/dashboard/app/components/BranchGroupCard.tsx b/packages/dashboard/app/components/BranchGroupCard.tsx index ced626c3be..e9987926bd 100644 --- a/packages/dashboard/app/components/BranchGroupCard.tsx +++ b/packages/dashboard/app/components/BranchGroupCard.tsx @@ -3,20 +3,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react"; import type { BranchGroupSummary } from "../api"; -import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api"; +import { ApiRequestError, apiAbandonBranchGroup, apiAssignTaskBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api"; import { subscribeSse } from "../sse-bus"; import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse"; interface BranchGroupCardProps { groupId: string; + taskId?: string; projectId?: string; + onBranchGroupReset?: () => Promise | void; } -export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { +export function BranchGroupCard({ groupId, taskId, projectId, onBranchGroupReset }: BranchGroupCardProps) { const { t } = useTranslation("app"); const [group, setGroup] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [missingGroup, setMissingGroup] = useState(false); + const [resetting, setResetting] = useState(false); const [promoting, setPromoting] = useState(false); const [abandoning, setAbandoning] = useState(false); /* @@ -30,8 +34,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { const response = await apiGetBranchGroup(groupId, projectId); setGroup(response.group); setError(null); + setMissingGroup(false); } catch (loadError) { + const isMissing = loadError instanceof ApiRequestError && loadError.status === 404; const message = loadError instanceof Error ? loadError.message : t("branchGroup.loadError", "Failed to load branch group"); + setMissingGroup(isMissing); setError(message); } finally { setLoading(false); @@ -96,11 +103,49 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { } }, [groupId, loadGroup, projectId]); + /* + FNXC:BranchGroupRecovery 2026-07-02-11:55: + Stale branch-group references must be recoverable from task detail by clearing only the affected task through the supported branch-group assignment API. Do not require operators to stop Fusion or run raw SQLite json_remove repairs. + */ + const onResetStaleContext = useCallback(async () => { + if (!taskId) return; + setResetting(true); + try { + await apiAssignTaskBranchGroup({ taskId, groupId: null }, projectId); + await onBranchGroupReset?.(); + } finally { + setResetting(false); + } + }, [onBranchGroupReset, projectId, taskId]); + if (loading) { return
{t("branchGroup.loading", "Loading branch group…")}
; } if (error || !group) { + if (missingGroup) { + return ( +
+
{t("branchGroup.staleTitle", "Stale branch group reference")}
+

+ {t( + "branchGroup.staleDescription", + "This task references branch group {{groupId}}, but that group is no longer available. Reset only this task's branch group to continue without raw SQLite edits.", + { groupId }, + )} +

+ {taskId ? ( + + ) : ( + {t("branchGroup.resetUnavailable", "Open the task detail view to reset this stale branch group reference.")} + )} +
+ ); + } + return
{error ?? t("branchGroup.unavailable", "Branch group unavailable")}
; } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 46068093ab..543e19c881 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -2517,6 +2517,12 @@ export function TaskDetailContent({ } }, [task.id, projectId, onTaskUpdated]); + const handleBranchGroupReset = useCallback(async () => { + const detail = await fetchTaskDetail(task.id, projectId); + setFullDetail(detail); + onTaskUpdated?.(detail); + }, [task.id, projectId, onTaskUpdated]); + const loadAgents = useCallback(async () => { setAgentsLoading(true); try { @@ -3484,7 +3490,13 @@ export function TaskDetailContent({ {shouldShowBranchGroupCard && task.branchContext?.groupId && ( /* FNXC:BranchGroupDetails 2026-06-30-00:00: Task-detail branch groups must return to their compact collapsed default when users switch tasks, including between members of the same shared branch group. Key by task and group so a manual expansion never leaks into the next task detail view. */ - + )} {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.worktree/task.branch; surface their acquired per-sub-repo worktrees diff --git a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx index 7afb932b50..421c1c529e 100644 --- a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx @@ -2,11 +2,13 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { BranchGroupCard } from "../BranchGroupCard"; +import { ApiRequestError } from "../../api"; import { loadAllAppCssBaseOnly } from "../../test/cssFixture"; const apiGetBranchGroup = vi.fn(); const apiPromoteBranchGroup = vi.fn(); const apiAbandonBranchGroup = vi.fn(); +const apiAssignTaskBranchGroup = vi.fn(); type SseSubscription = { url: string; @@ -19,9 +21,17 @@ type SseSubscription = { const sseSubscriptions: SseSubscription[] = []; vi.mock("../../api", () => ({ + ApiRequestError: class ApiRequestError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } + }, apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args), apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args), apiAbandonBranchGroup: (...args: unknown[]) => apiAbandonBranchGroup(...args), + apiAssignTaskBranchGroup: (...args: unknown[]) => apiAssignTaskBranchGroup(...args), })); vi.mock("../../sse-bus", () => ({ @@ -74,6 +84,7 @@ describe("BranchGroupCard", () => { apiGetBranchGroup.mockReset(); apiPromoteBranchGroup.mockReset(); apiAbandonBranchGroup.mockReset(); + apiAssignTaskBranchGroup.mockReset(); sseSubscriptions.length = 0; }); @@ -85,6 +96,35 @@ describe("BranchGroupCard", () => { expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull(); }); + it("FN-7438: guides users to reset a stale branch group reference and clears only this task", async () => { + apiGetBranchGroup.mockRejectedValue(new ApiRequestError("Branch group not found", 404)); + apiAssignTaskBranchGroup.mockResolvedValue({ taskId: "FN-1", groupId: null }); + const onBranchGroupReset = vi.fn(); + + render(); + + expect(await screen.findByText("Stale branch group reference")).toBeInTheDocument(); + expect(screen.getByText(/Reset only this task's branch group/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /expand branch group/i })).toBeNull(); + expect(screen.queryByRole("progressbar")).toBeNull(); + const reset = screen.getByRole("button", { name: /reset branch group for this task/i }); + fireEvent.click(reset); + + await waitFor(() => { + expect(apiAssignTaskBranchGroup).toHaveBeenCalledWith({ taskId: "FN-1", groupId: null }, "proj-a"); + expect(onBranchGroupReset).toHaveBeenCalledTimes(1); + }); + }); + + it("keeps transient branch-group load failures separate from stale-reference reset UI", async () => { + apiGetBranchGroup.mockRejectedValue(new Error("network down")); + + render(); + + expect(await screen.findByText("network down")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /reset branch group for this task/i })).toBeNull(); + }); + it("shows promote and calls API when complete + autoMerge off", async () => { apiGetBranchGroup .mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }] }) }) diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index df32a10030..906caea506 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -21,12 +21,14 @@ import { import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal"; vi.mock("../BranchGroupCard", () => ({ - BranchGroupCard: ({ groupId }: { groupId: string }) => { + BranchGroupCard: ({ groupId, taskId, onBranchGroupReset }: { groupId: string; taskId?: string; onBranchGroupReset?: () => void }) => { const [expanded, setExpanded] = React.useState(false); return (
Mock Branch Group {groupId} + {taskId && Mock Branch Group Task {taskId}} + {onBranchGroupReset && } {expanded && Mock branch group expanded}
); @@ -883,6 +885,13 @@ describe("TaskDetailModal branch group surfacing", () => { expect(screen.getByText("Mock Branch Group BG-1")).toBeInTheDocument(); }); + it("FN-7438: passes task identity and reset callback to stale branch-group recovery", () => { + render(renderTaskWithBranchContext("FN-6041")); + + expect(screen.getByText("Mock Branch Group Task FN-6041")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Mock reset stale branch group" })).toBeInTheDocument(); + }); + it("remounts the branch group card when switching tasks inside the same group", async () => { const user = userEvent.setup(); const { rerender } = render(renderTaskWithBranchContext("FN-6041")); diff --git a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts index f9d4ed442c..cd9085839b 100644 --- a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts +++ b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts @@ -2,7 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; -import type { BranchGroup, Task, TaskStore } from "@fusion/core"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import type { BranchGroup, Task } from "@fusion/core"; import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine"; import { createApiRoutes } from "../routes.js"; import { createBranchGroupsRouter } from "../routes/register-branch-groups-routes.js"; @@ -216,6 +221,78 @@ describe("branch group routes", () => { }); }); +describe("branch group routes with durable TaskStore", () => { + async function withRestartedStore(callback: (store: TaskStore, app: express.Express) => Promise): Promise { + const rootDir = mkdtempSync(join(tmpdir(), "fusion-branch-group-route-")); + const globalDir = join(rootDir, ".fusion-global"); + let store = new TaskStore(rootDir, globalDir); + await store.init(); + try { + const group = store.ensureBranchGroupForSource("planning", "PS-route-restart", { + branchName: "feature/route-restart", + autoMerge: true, + }); + await store.createTask({ + description: "route member after restart", + branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, + }); + store.close(); + store = new TaskStore(rootDir, globalDir); + await store.init(); + + const app = express(); + app.use(express.json()); + app.use("/api/branch-groups", createBranchGroupsRouter(store)); + attachErrorHandler(app); + return await callback(store, app); + } finally { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + } + + it("FN-7438: lists and shows persisted branch groups after a server/store restart", async () => { + await withRestartedStore(async (store, app) => { + const group = store.getBranchGroupBySource("planning", "PS-route-restart"); + expect(group?.id).toMatch(/^BG-/); + + const listRes = await REQUEST(app, "GET", "/api/branch-groups"); + expect(listRes.status).toBe(200); + expect(listRes.body.groups.map((entry: { id: string }) => entry.id)).toContain(group!.id); + expect(listRes.body.groups.find((entry: { id: string }) => entry.id === group!.id).completion.total).toBe(1); + + const showRes = await REQUEST(app, "GET", `/api/branch-groups/${group!.id}`); + expect(showRes.status).toBe(200); + expect(showRes.body.group.id).toBe(group!.id); + expect(showRes.body.group.members).toHaveLength(1); + expect(showRes.body.group.branchName).toBe("feature/route-restart"); + }); + }); + + it("FN-7438: clears only one stale task branch context through the assign API", async () => { + await withRestartedStore(async (store, app) => { + const stale = await store.createTask({ + description: "stale branch context", + source: { sourceType: "api", sourceMetadata: { externalKey: "preserve" } }, + branchContext: { groupId: "BG-missing", source: "planning", assignmentMode: "shared" }, + }); + const peer = await store.createTask({ + description: "peer branch context", + branchContext: { groupId: "BG-other-missing", source: "planning", assignmentMode: "shared" }, + }); + + const res = await REQUEST(app, "POST", "/api/branch-groups/assign", JSON.stringify({ taskId: stale.id, groupId: null }), { "content-type": "application/json" }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ taskId: stale.id, groupId: null }); + + const cleared = await store.getTask(stale.id); + expect(cleared.branchContext).toBeUndefined(); + expect(cleared.sourceMetadata).toEqual({ externalKey: "preserve" }); + expect((await store.getTask(peer.id)).branchContext?.groupId).toBe("BG-other-missing"); + }); + }); +}); + describe("branch group abandon (U6, R7)", () => { function buildOpenGroup(): BranchGroup { return { diff --git a/packages/engine/src/__tests__/integration-branch.test.ts b/packages/engine/src/__tests__/integration-branch.test.ts index 2ddbb3124d..d89da6bffe 100644 --- a/packages/engine/src/__tests__/integration-branch.test.ts +++ b/packages/engine/src/__tests__/integration-branch.test.ts @@ -85,6 +85,47 @@ describe("integration-branch resolver", () => { expect(first).toBe(INTEGRATION_BRANCH_FALLBACK); expect(second).toBe(INTEGRATION_BRANCH_FALLBACK); expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("origin/HEAD unset and no project override")); + }); + + it("falls back to main with actionable guidance when origin is absent but gitlab remote exists", async () => { + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("origin/HEAD")) { + cb(new Error("no symbolic ref"), { stdout: "" }); + return {}; + } + cb(null, { stdout: "gitlab\n" }); + return {}; + }); + const warn = vi.fn(); + + const resolved = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } }); + + expect(resolved).toBe(INTEGRATION_BRANCH_FALLBACK); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("auto-detect checks origin/HEAD"); + expect(warn.mock.calls[0]?.[0]).toContain("origin is absent"); + expect(warn.mock.calls[0]?.[0]).toContain("found remote gitlab"); + expect(warn.mock.calls[0]?.[0]).toContain("set integrationBranch manually"); + }); + + it("warns once per rootDir when origin exists but has no HEAD", async () => { + execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => { + if (command.includes("origin/HEAD")) { + cb(new Error("no symbolic ref"), { stdout: "" }); + return {}; + } + cb(null, { stdout: "origin\ngitlab\norigin\n" }); + return {}; + }); + const warn = vi.fn(); + + await expect(resolveIntegrationBranch("/repo", undefined, { logger: { warn } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK); + await expect(resolveIntegrationBranch("/repo", undefined, { logger: { warn } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("origin/HEAD is unset"); + expect(warn.mock.calls[0]?.[0]).toContain("found remote origin, gitlab"); }); it("sync and async variants match", async () => { diff --git a/packages/engine/src/integration-branch.ts b/packages/engine/src/integration-branch.ts index 74a3e740a9..314e685050 100644 --- a/packages/engine/src/integration-branch.ts +++ b/packages/engine/src/integration-branch.ts @@ -25,11 +25,21 @@ function normalize(value: unknown): string { .replace(/^origin\//, ""); } -function warnFallback(rootDir: string, logger: Pick): void { +/* +FNXC:IntegrationBranch 2026-07-02-11:59: +Auto-detect deliberately reads origin/HEAD only. When operators use another remote such as gitlab, fallback diagnostics must name discovered remotes and guide them to add an origin alias or set integrationBranch manually instead of silently choosing an arbitrary remote. +*/ +function warnFallback(rootDir: string, logger: Pick, remotes: string[] = []): void { if (warnedFallbackRootDirs.has(rootDir)) { return; } warnedFallbackRootDirs.add(rootDir); + if (remotes.length > 0) { + const remoteList = remotes.join(", "); + const originState = remotes.includes("origin") ? "origin/HEAD is unset" : "origin is absent"; + logger.warn(`[integration-branch] falling back to 'main' — auto-detect checks origin/HEAD, but ${originState}; found remote ${remoteList}. Add an origin alias or set integrationBranch manually.`); + return; + } logger.warn("[integration-branch] falling back to 'main' — origin/HEAD unset and no project override"); } @@ -71,6 +81,42 @@ function resolveFromOriginHeadSync(rootDir: string): string { } } +function parseRemotes(stdout: string): string[] { + return [...new Set(stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean))]; +} + +async function listGitRemotes(rootDir: string): Promise { + try { + const { stdout } = await execAsync("git remote", { + cwd: rootDir, + encoding: "utf8", + timeout: 5_000, + maxBuffer: 1024 * 1024, + }); + return parseRemotes(stdout); + } catch { + return []; + } +} + +function listGitRemotesSync(rootDir: string): string[] { + try { + const stdout = execSync("git remote", { + cwd: rootDir, + encoding: "utf8", + timeout: 5_000, + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + return parseRemotes(stdout); + } catch { + return []; + } +} + export async function resolveIntegrationBranch( rootDir: string, settings: IntegrationBranchSettings, @@ -88,7 +134,8 @@ export async function resolveIntegrationBranch( return fromOrigin; } - warnFallback(rootDir, logger); + const remotes = await listGitRemotes(rootDir); + warnFallback(rootDir, logger, remotes); return INTEGRATION_BRANCH_FALLBACK; } @@ -109,7 +156,8 @@ export function resolveIntegrationBranchSync( return fromOrigin; } - warnFallback(rootDir, logger); + const remotes = listGitRemotesSync(rootDir); + warnFallback(rootDir, logger, remotes); return INTEGRATION_BRANCH_FALLBACK; }