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:
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -195,7 +195,6 @@ const qualityAppComponentTests = [
|
||||
"TaskDetailModal.create-pr-integration",
|
||||
"TaskDetailModal.github-tracking-header",
|
||||
"TaskDetailModal.github-tracking-stale",
|
||||
"TaskDetailModal.rebind-banner",
|
||||
"TaskDocumentsTab",
|
||||
"TaskFieldsSection",
|
||||
"TaskForm",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user