fix(workspace): remove false reattach banner, silence ai-merge ENOENT prune, skip workspace in rebinder (#1748)

Follow-up polish on the multiworkspace merge flow (after #1747 merged).
Driven by live testing of multi-repo tasks.

## Changes

**1. Remove the "Branch needs reattachment" banner (dashboard)**
The banner 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
`workspaceWorktrees`, not a root branch). So it was a permanent false
positive on every in-review workspace task. Genuine lost bindings
(non-workspace) are already reattached **automatically** by
self-healing's `reconcileInReviewBranchRebind`, which runs event-driven
on move-to-in-review and on the sweep — no manual user action needed.
Removed the banner UI, its handler/state/imports, the obsolete
`rebind-banner` test, and its registry entry.

**2. self-healing: skip workspace tasks in the branch rebinder
(engine)**
`reconcileInReviewBranchRebind` now explicitly skips workspace tasks.
They are never rebind candidates — their `fusion/<id>` branches live
inside each sub-repo, not in the non-git browse-only workspace root, so
a null root branch is healthy. (The slim list select now carries
`workspaceWorktrees` from #1747, so `isWorkspaceTask` is accurate on
slim rows.)

**3. merger-ai: silence ENOENT in the pre-merge prune (engine)**
The AI-merge pre-merge prune `readdirSync`'d `<repo>/.fusion/ai-merge`
and warned on every workspace merge because that clean-room root is
created lazily and is normally absent. ENOENT now means "nothing to
prune" — skipped silently; non-ENOENT failures (and the tmpdir case)
still surface/throw.

**4. test: add `ToggleRight` to the TaskDetailModal lucide mock**
Pre-existing gap from FN-6880 (optional-group node) that broke the
entire TaskDetailModal suite at import; unrelated to this change but
blocking validation.

## Validation
- engine typecheck + build clean; dashboard typecheck clean
- engine rebind + merger-ai suites pass (63); TaskDetailModal suites
pass (117) after the mock fix
- lint clean on changed files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- stage-review-badge-begin -->

---

<a href="https://stagereview.app/Runfusion/Fusion/pull/1748">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg">
<img src="https://stagereview.app/assets/gh-open-in-stage-light.svg"
alt="Open in Stage">
  </picture>
</a>

<!-- stage-review-badge-end -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Removed the “Branch needs reattachment” banner and reattach action
from task details.
* Workspace tasks are skipped during branch rebind checks to avoid
confusing recovery prompts.
  * Temporary AI-merge cleanup now handles missing directories quietly.
* Workspace per-repo landing now continues when dependency
synchronization fails (with degraded dependency availability), while
single-repo landing still fails fast.
* **Tests**
* Updated component test mocks and removed the rebind-banner test
coverage.
* Added coverage for resilient workspace dependency synchronization
behavior and adjusted workspace-merger test fixtures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-06-24 17:45:01 -07:00
committed by GitHub
8 changed files with 241 additions and 199 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

@@ -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<typeof import("../merge-dependency-sync.js")>();
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<void> => {
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<string> => "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/);
});
});

View File

@@ -60,7 +60,10 @@ function createStore(settings: Record<string, unknown> = {}): 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);

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;
@@ -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<ReturnType<typeof installWorktreeDependencies>> | 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") {

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 {