From 93cfb88e64e5411c84afdf261d0b5496b2425aca Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 16:23:50 -0700 Subject: [PATCH] fix(workspace): remove false reattach banner, silence ai-merge ENOENT prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard: remove the "Branch needs reattachment" banner. It fired for any in-review task with a null singular task.branch — the NORMAL state for a workspace task (attachment is per-sub-repo worktrees in workspaceWorktrees), so it was a permanent false positive. Genuine lost bindings are already reattached automatically by self-healing's reconcileInReviewBranchRebind (event-driven on move-to-in-review + sweep), so no manual user action is needed. Delete the now-obsolete rebind-banner test + its registry entry. - engine/self-healing: reconcileInReviewBranchRebind now explicitly skips workspace tasks (never rebind candidates — their fusion/ branches live in the sub-repos, not the non-git browse root; null root branch is healthy). - engine/merger-ai: pre-merge prune treats an absent ai-merge search root (ENOENT) as "nothing to prune" instead of warning on every workspace merge. - test: add ToggleRight to the TaskDetailModal lucide mock (pre-existing gap from FN-6880 that broke the whole suite at import). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/TaskDetailModal.tsx | 74 ++---------- .../TaskDetailModal.rebind-banner.test.tsx | 110 ------------------ .../__tests__/TaskDetailModal.test-helpers.ts | 4 + packages/dashboard/vitest.config.ts | 1 - packages/engine/src/merger-ai.ts | 10 ++ packages/engine/src/self-healing.ts | 18 ++- 6 files changed, 42 insertions(+), 175 deletions(-) delete mode 100644 packages/dashboard/app/components/__tests__/TaskDetailModal.rebind-banner.test.tsx diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 9ce9f76d86..606724c8c9 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -24,8 +24,8 @@ import { } from "@fusion/core"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; -import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; +import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; +import type { WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { ApiRequestError } from "../api"; import { TaskFieldsSection } from "./TaskFieldsSection"; import type { ToastType } from "../hooks/useToast"; @@ -936,9 +936,7 @@ export function TaskDetailContent({ const [githubTrackingEnabledDraft, setGithubTrackingEnabledDraft] = useState(null); const [githubRepoOverrideError, setGithubRepoOverrideError] = useState(null); const [isSavingGithubTracking, setIsSavingGithubTracking] = useState(false); - const [isRecoveringBranchBinding, setIsRecoveringBranchBinding] = useState(false); const [isCheckingPrStatus, setIsCheckingPrStatus] = useState(false); - const [recoverBranchBindingOutcome, setRecoverBranchBindingOutcome] = useState(null); const moveMenuRef = useRef(null); const activityListRef = useRef(null); const moveButtonRef = useRef(null); @@ -1045,8 +1043,6 @@ export function TaskDetailContent({ setGithubTrackingEnabledDraft(null); setGithubRepoOverrideError(null); setIsEditing(false); - setRecoverBranchBindingOutcome(null); - setIsRecoveringBranchBinding(false); }, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, workingTask.githubTracking]); useEffect(() => { @@ -2178,25 +2174,6 @@ export function TaskDetailContent({ }, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]); const isTaskPaused = task.paused || task.userPaused; - const showRecoverBranchBindingBanner = task.column === "in-review" && !task.branch; - - const handleRecoverBranchBinding = useCallback(async () => { - setIsRecoveringBranchBinding(true); - try { - const outcome = await recoverBranchBinding(task.id, projectId); - setRecoverBranchBindingOutcome(outcome); - if (outcome.result === "applied") { - addToast(t("taskDetail.branchBinding.reattached", "Reattached branch for {{id}} ({{branch}})", { id: task.id, branch: outcome.branch }), "success"); - onTaskUpdated?.({ ...task, branch: outcome.branch, worktree: undefined }); - } else { - addToast(t("taskDetail.branchBinding.skipped", "Branch reattachment skipped for {{id}}: {{reason}}", { id: task.id, reason: outcome.reason }), "info"); - } - } catch (err) { - addToast(getErrorMessage(err), "error"); - } finally { - setIsRecoveringBranchBinding(false); - } - }, [addToast, onTaskUpdated, projectId, task]); const handleTogglePause = useCallback(async () => { try { @@ -4299,44 +4276,15 @@ export function TaskDetailContent({ addToast={addToast} /> )} - {showRecoverBranchBindingBanner && ( -
-
-
-

- {t("taskDetail.branchBinding.copy", "This in-review task isn't currently attached to a fusion branch. If a live fusion branch still exists for it, you can reattach it here.")} -

- {recoverBranchBindingOutcome && ( -
- {recoverBranchBindingOutcome.result === "applied" - ? t("taskDetail.branchBinding.reattachedResult", "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", { branch: recoverBranchBindingOutcome.branch, count: recoverBranchBindingOutcome.aheadCount, base: recoverBranchBindingOutcome.integrationBase }) - : t("taskDetail.branchBinding.skippedResult", "Reattachment skipped: {{reason}}", { reason: recoverBranchBindingOutcome.reason })} - {recoverBranchBindingOutcome.result === "skipped" && recoverBranchBindingOutcome.candidates?.length ? ( - - {` ${t("taskDetail.branchBinding.candidates", "Candidates:")} ${recoverBranchBindingOutcome.candidates.map((entry) => `${entry.branch} (${entry.aheadCount})`).join(", ")}`} - - ) : null} -
- )} -
- -
-
- )} + {/* + FNXC:Workspace 2026-06-24-23:10: + The "Branch needs reattachment" banner was removed. It fired for any in-review task with a + null singular `task.branch`, which is the NORMAL, healthy state for a workspace task (its + attachment is the per-sub-repo worktrees in `task.workspaceWorktrees`, not a root branch), so + the banner was a permanent false positive for workspace tasks. Reattachment of a genuinely + lost binding is handled automatically by self-healing's reconcileInReviewBranchRebind, which + runs event-driven on the move-to-in-review and on its sweep — no manual user action needed. + */}
{isEditing ? ( <> diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rebind-banner.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rebind-banner.test.tsx deleted file mode 100644 index 2a922920b5..0000000000 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rebind-banner.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { TaskDetailModal } from "../TaskDetailModal"; -import * as api from "../../api"; -import { makeTask, noop, noopDelete, noopMerge, noopMove, noopOpenDetail, setupTaskDetailModalHooks } from "./TaskDetailModal.test-helpers"; - -setupTaskDetailModalHooks(); - -describe("TaskDetailModal rebind banner", () => { - it("shows banner only for in-review tasks with missing branch", () => { - const { rerender } = render( - , - ); - - expect(screen.getByText("Branch needs reattachment")).toBeTruthy(); - - rerender( - , - ); - // FN-5113: branch present + worktree cleared is the healthy post-handoff/post-rebind state (see AGENTS.md FN-5083). Banner must NOT show. - expect(screen.queryByText("Branch needs reattachment")).toBeNull(); - - rerender( - , - ); - expect(screen.queryByText("Branch needs reattachment")).toBeNull(); - }); - - it("calls recover endpoint and renders applied result", async () => { - const recoverSpy = vi.spyOn(api, "recoverBranchBinding").mockResolvedValueOnce({ - taskId: "FN-099", - result: "applied", - branch: "fusion/fn-099", - aheadCount: 2, - integrationBase: "main", - previousBranch: null, - }); - - render( - , - ); - - await userEvent.click(screen.getByRole("button", { name: "Reattach branch" })); - - expect(recoverSpy).toHaveBeenCalledWith("FN-099", undefined); - expect(await screen.findByText(/Reattached fusion\/fn-099/)).toBeTruthy(); - }); - - it("renders skipped reason and candidates", async () => { - vi.spyOn(api, "recoverBranchBinding").mockResolvedValueOnce({ - taskId: "FN-099", - result: "skipped", - reason: "ambiguous-candidates", - candidates: [ - { branch: "fusion/FN-099", aheadCount: 1 }, - { branch: "fusion/fn-099", aheadCount: 2 }, - ], - }); - - render( - , - ); - - await userEvent.click(screen.getByRole("button", { name: "Reattach branch" })); - - expect(await screen.findByText(/Reattachment skipped: ambiguous-candidates/)).toBeTruthy(); - expect(screen.getByText(/fusion\/FN-099/)).toBeTruthy(); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index d7cc3ce404..080d8ae781 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -81,6 +81,10 @@ vi.mock("lucide-react", () => ({ Split: () => null, Merge: () => null, Repeat: () => null, + // FNXC:Test 2026-06-24-23:30: WorkflowNodeEditor (lazy-loaded by TaskDetailModal) uses ToggleRight + // for the optional-group node (FN-6880); the explicit mock list omitted it, breaking every + // TaskDetailModal suite at import. Keep this list in sync with the node-editor icon set. + ToggleRight: () => null, ClipboardCheck: () => null, ListChecks: () => null, Code2: () => null, diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 93a4e66607..b1c1b1f086 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -195,7 +195,6 @@ const qualityAppComponentTests = [ "TaskDetailModal.create-pr-integration", "TaskDetailModal.github-tracking-header", "TaskDetailModal.github-tracking-stale", - "TaskDetailModal.rebind-banner", "TaskDocumentsTab", "TaskFieldsSection", "TaskForm", diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 1194dc2420..dca83adb29 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -195,6 +195,16 @@ export async function pruneExistingAiMergeWorktrees( try { entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix)); } catch (err: unknown) { + /* + FNXC:AiMerge 2026-06-24-23:10: + An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory + (e.g. `/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a + workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means + "nothing to prune" — skip it silently rather than emitting an alarming warning on every merge. + Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir + (which always exists) remains fatal. + */ + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue; await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`); if (tempRoot === tmpdir()) throw err; continue; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 3bddad24f4..2a0807bf7e 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -575,7 +575,8 @@ type RebindOutcome = | "ambiguous-candidates" | "no-unique-work" | "unsafe-to-auto-mutate:user-paused" - | "unsafe-to-auto-mutate:checked-out"; + | "unsafe-to-auto-mutate:checked-out" + | "workspace-task"; candidates?: Array<{ branch: string; aheadCount: number }>; }; @@ -3884,6 +3885,21 @@ export class SelfHealingManager { for (const task of tasks) { if (options?.includeTaskIds && !options.includeTaskIds.has(task.id)) continue; + /* + FNXC:Workspace 2026-06-24-23:10: + A workspace task is NEVER a branch-rebind candidate. Its attachment is the per-sub-repo + worktrees in `task.workspaceWorktrees`, and its `fusion/` branches live inside each + sub-repo — not in `this.options.rootDir`, which for a workspace is the non-git browse-only + root. A null `task.branch` is its HEALTHY steady state, so trying to rebind a root branch is + meaningless (every git probe below would fail-soft against the non-git root anyway). Skip it + explicitly. The slim list select now carries `workspaceWorktrees`, so `isWorkspaceTask` is + accurate on these slim rows. + */ + if (isWorkspaceTask(task)) { + result.outcomes.push({ taskId: task.id, result: "skipped", reason: "workspace-task" }); + continue; + } + const existingBinding = task.branch; if (existingBinding) { try {