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/__tests__/workspace-merger-deps-resilient.test.ts b/packages/engine/src/__tests__/workspace-merger-deps-resilient.test.ts new file mode 100644 index 0000000000..ed8184e5cd --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-deps-resilient.test.ts @@ -0,0 +1,135 @@ +/* +FNXC:Workspace 2026-06-24-23:50 (resilient workspace land — dependency-sync failure): +A workspace per-repo land must NOT be blocked by one sub-repo whose clean-room `npm install` +fails (e.g. a corrupt `-@0.0.1` lockfile entry npm 11 rejects). The git squash does not need +installed deps; only dep-dependent merge verification degrades. landWorkspaceTask sets +`nonFatalDependencySync` on landOneRepo so the install throw is caught, logged, and the land +proceeds. The single-repo land path keeps the documented HARD-fail (flag defaults off). + +We drive the REAL landWorkspaceTask / landOneRepo against a REAL git fixture with injected +agents (the squash is a plain `git merge --squash`, no AI), and MOCK installWorktreeDependencies +to throw — so no real/slow/networked npm runs (FN-5048). +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; + +vi.mock("../merge-dependency-sync.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, installWorktreeDependencies: vi.fn() }; +}); + +import { installWorktreeDependencies } from "../merge-dependency-sync.js"; +import { landWorkspaceTask, landOneRepo } from "../merger-ai.js"; +import { createRunAuditor, generateSyntheticRunId } from "../run-audit.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; +const TASK_ID = "FN-3001"; +const BRANCH = "fusion/fn-3001"; +const NPM_FAILURE = new Error("Dependency sync failed for FN-3001: npm error EINVALIDPACKAGENAME Invalid package name \"-\" of package \"-@0.0.1\""); + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +function createStore(): TaskStore & { logs: string[] } { + const emitter = new EventEmitter(); + const logs: string[] = []; + return Object.assign(emitter, { + logs, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn((_id: string, message: string) => { logs.push(message); return Promise.resolve(undefined); }), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + // mergeAndReview reads store.getTask().comments for prompt context — return a real task shape. + getTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "in-review", branch: BRANCH, comments: [], steeringComments: [], steps: [], log: [] }), + moveTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "done" } as Task), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + }) as unknown as TaskStore & { logs: string[] }; +} + +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`); + configureIdentity(wt); + writeFileSync(path.join(wt, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +const squashMergeAgent = async (cwd: string): Promise => { + configureIdentity(cwd); + try { execSync(`git merge --squash ${BRANCH}`, { cwd, stdio: "pipe" }); } catch { /* conflicts handled below */ } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) throw new Error("merge conflict: unresolved paths in clean room"); + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${BRANCH}: squashed"`, { cwd, stdio: "pipe" }); +}; +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id: TASK_ID, title: "Workspace merge task", description: "", column: "in-review", + branch: BRANCH, dependencies: [], steps: [], currentStep: 0, log: [], workspaceWorktrees, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("workspace land — dependency-sync failure resilience", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("lands ALL sub-repos even when clean-room dependency sync fails (non-fatal)", async () => { + vi.mocked(installWorktreeDependencies).mockRejectedValue(NPM_FAILURE); + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent, + reviewAgent: approveReviewAgent, + }); + + // Despite every per-repo install throwing, both repos land and the integration ref advances. + expect(result.allLanded).toBe(true); + for (const r of result.repos) expect(r.status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); + // The degradation is surfaced, not swallowed silently. + expect(store.logs.some((m) => /dependency sync FAILED/i.test(m) && /deps unavailable/i.test(m))).toBe(true); + }); + + it("single-repo land (flag off) still HARD-fails on a dependency-sync failure", async () => { + vi.mocked(installWorktreeDependencies).mockRejectedValue(NPM_FAILURE); + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const store = createStore(); + const audit = createRunAuditor(store, { runId: generateSyntheticRunId("ai-merge", TASK_ID), agentId: "merger", taskId: TASK_ID, phase: "merge" }); + + // landOneRepo WITHOUT nonFatalDependencySync → the documented hard-fail must propagate. + await expect( + landOneRepo(fx.repoPath("repo-a"), BRANCH, "main", { + taskId: TASK_ID, settings: { autoMerge: false } as never, audit, + log: async () => undefined, setStatus: async () => undefined, maxPasses: 1, + mergeAgent: squashMergeAgent, reviewAgent: approveReviewAgent, stashResolveAgent: async () => undefined, + includeTaskId: true, trailers: [], store, + // nonFatalDependencySync intentionally omitted (defaults off) + }), + ).rejects.toThrow(/Invalid package name/); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index 8973c66c70..9a4d91aa16 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -60,7 +60,10 @@ function createStore(settings: Record = {}): TaskStore & Record updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined), appendAgentLog: vi.fn().mockResolvedValue(undefined), - getTask: vi.fn().mockResolvedValue(undefined), + // FNXC:Test 2026-06-24-23:50: mergeAndReview reads store.getTask().comments for merge/review + // prompt context (selectUserCommentsForAgentContext); an undefined return throws mid-land. Return + // a real task shape so the per-repo land reaches landSquash. + getTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "in-review", branch: BRANCH, comments: [], steeringComments: [], steps: [], log: [] }), moveTask: vi.fn((id: string, column: string) => { moveTaskCalls.push({ id, column }); return Promise.resolve({ id, column } as Task); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 1194dc2420..2c9bd2ade1 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; @@ -1003,6 +1013,15 @@ export interface LandRepoContext { taskTitle?: string; signal?: AbortSignal; allowDirtyLocalCheckoutSync?: boolean; + /* + FNXC:Workspace 2026-06-24-23:50 (resilient workspace land): + When true, a clean-room dependency-sync FAILURE is non-fatal: the land proceeds (the git squash + does not need installed deps) and only dep-dependent merge verification degrades for this repo. + Set on the workspace per-repo land so one sub-repo's broken/corrupt package manifest (e.g. an + invalid `-@0.0.1` lockfile entry npm rejects) cannot block landing the other sub-repos. Defaults + off, preserving the documented hard-fail for the single-repo land path. + */ + nonFatalDependencySync?: boolean; store: TaskStore; } @@ -1099,30 +1118,55 @@ export async function landOneRepo( * The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run. */ const depsSyncStartedAt = Date.now(); - const depsSyncResult = await installWorktreeDependencies({ - cwd: canonicalMergeRoot, - settings, - taskId, - signal, - context: "for AI merge clean room", - logger: aiMergeLog, - log, - }); - await audit.git({ - type: "merge:ai-deps-sync", - target: integrationBranch, - metadata: { + let depsSyncResult: Awaited> | null = null; + try { + depsSyncResult = await installWorktreeDependencies({ + cwd: canonicalMergeRoot, + settings, taskId, - tipSha, - mergeRoot: canonicalMergeRoot, - installCommand: depsSyncResult.installCommand, - configured: depsSyncResult.configured, - skipped: depsSyncResult.skipped, - skipReason: depsSyncResult.skipReason, - durationMs: depsSyncResult.durationMs, - }, - }); - await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`); + signal, + context: "for AI merge clean room", + logger: aiMergeLog, + log, + }); + } catch (depsErr: unknown) { + /* + FNXC:Workspace 2026-06-24-23:50 (resilient workspace land): + The default contract hard-fails install errors so verification cannot silently run against an + uninstalled checkout. For a WORKSPACE per-repo land (ctx.nonFatalDependencySync) we instead + degrade: the git squash does not need installed deps, so one sub-repo whose manifest npm + refuses to install (e.g. a corrupt `-@0.0.1` lockfile entry) must not block landing the + others. Log + audit the degradation and proceed; the merge/review agents still run (they just + cannot run dep-dependent build/test verification for this repo). A genuine abort signal still + propagates. Non-workspace land keeps the original throw. + */ + throwIfAborted(signal, taskId); + if (!ctx.nonFatalDependencySync) throw depsErr; + const depsErrMessage = getErrorMessage(depsErr); + await log(`AI merge (workspace): dependency sync FAILED for this sub-repo's clean room — landing without dep-dependent verification (deps unavailable): ${depsErrMessage}`); + await audit.git({ + type: "merge:ai-deps-sync", + target: integrationBranch, + metadata: { taskId, tipSha, mergeRoot: canonicalMergeRoot, failed: true, nonFatal: true, error: depsErrMessage, durationMs: Date.now() - depsSyncStartedAt }, + }); + } + if (depsSyncResult) { + await audit.git({ + type: "merge:ai-deps-sync", + target: integrationBranch, + metadata: { + taskId, + tipSha, + mergeRoot: canonicalMergeRoot, + installCommand: depsSyncResult.installCommand, + configured: depsSyncResult.configured, + skipped: depsSyncResult.skipped, + skipReason: depsSyncResult.skipReason, + durationMs: depsSyncResult.durationMs, + }, + }); + } + await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult ? (depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)") : " (failed — non-fatal, deps unavailable)"}`); // 2 + 3. Merge + review loop (corrective passes). const squashSha = await mergeAndReview({ @@ -1616,6 +1660,9 @@ export async function landWorkspaceTask( mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, + // FNXC:Workspace 2026-06-24-23:50: one sub-repo's dependency-sync failure must not block + // landing the others — degrade verification for that repo, still land the git squash. + nonFatalDependencySync: true, store, }); if (landResult.outcome === "landed") { 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 {