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:
@@ -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",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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