fix(workspace): remove false reattach banner, silence ai-merge ENOENT prune

- 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/<id> 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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-24 16:23:50 -07:00
parent 28ceca2cbd
commit 93cfb88e64
6 changed files with 42 additions and 175 deletions

View File

@@ -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<boolean | null>(null);
const [githubRepoOverrideError, setGithubRepoOverrideError] = useState<string | null>(null);
const [isSavingGithubTracking, setIsSavingGithubTracking] = useState(false);
const [isRecoveringBranchBinding, setIsRecoveringBranchBinding] = useState(false);
const [isCheckingPrStatus, setIsCheckingPrStatus] = useState(false);
const [recoverBranchBindingOutcome, setRecoverBranchBindingOutcome] = useState<RecoverBranchBindingOutcome | null>(null);
const moveMenuRef = useRef<HTMLDivElement>(null);
const activityListRef = useRef<HTMLDivElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(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 && (
<div className="detail-section rebind-banner" role="status">
<div className="rebind-banner-header">
<GitBranch aria-hidden="true" />
<span className="rebind-banner-headline">{t("taskDetail.branchBinding.headline", "Branch needs reattachment")}</span>
</div>
<p className="rebind-banner-copy">
{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.")}
</p>
{recoverBranchBindingOutcome && (
<div className="rebind-banner-result">
{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 ? (
<span>
{` ${t("taskDetail.branchBinding.candidates", "Candidates:")} ${recoverBranchBindingOutcome.candidates.map((entry) => `${entry.branch} (${entry.aheadCount})`).join(", ")}`}
</span>
) : null}
</div>
)}
<div className="rebind-banner-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => void handleRecoverBranchBinding()}
disabled={isRecoveringBranchBinding}
>
{isRecoveringBranchBinding ? (
<>
<Loader2 size={16} className="spin" aria-hidden="true" />
{t("taskDetail.branchBinding.reattaching", "Reattaching…")}
</>
) : t("taskDetail.branchBinding.reattachBtn", "Reattach branch")}
</button>
</div>
</div>
)}
{/*
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.
*/}
<div className="modal-actions">
{isEditing ? (
<>

View File

@@ -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(
<TaskDetailModal
task={makeTask({ column: "in-review", branch: null, worktree: "/tmp/wt" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Branch needs reattachment")).toBeTruthy();
rerender(
<TaskDetailModal
task={makeTask({ column: "in-review", branch: "fusion/fn-099", worktree: null })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// 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(
<TaskDetailModal
task={makeTask({ column: "in-review", branch: "fusion/fn-099", worktree: "/tmp/wt" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({ column: "in-review", branch: null, worktree: null })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({ column: "in-review", branch: null, worktree: null })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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();
});
});

View File

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

View File

@@ -195,7 +195,6 @@ const qualityAppComponentTests = [
"TaskDetailModal.create-pr-integration",
"TaskDetailModal.github-tracking-header",
"TaskDetailModal.github-tracking-stale",
"TaskDetailModal.rebind-banner",
"TaskDocumentsTab",
"TaskFieldsSection",
"TaskForm",

View File

@@ -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. `<repo>/.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;

View File

@@ -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/<id>` 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 {