perf(merger): skip redundant in-merge verification and lockfile-stable installs
Two cuts to wasted work in the merge verification loop: 1. After the in-merge fix agent runs, fingerprint the working tree (`git diff HEAD` + `git status --porcelain`, sha256). If the post-fix fingerprint matches pre-fix and is non-empty, the agent didn't actually change anything — re-running the same failing command can only yield the same failure, so log and report the attempt as unsuccessful without paying the test/build cost. Empty fingerprints (snapshot tooling failed) fall through to the existing re-run path so we never silently swallow a real fix. 2. Inside `syncDependenciesForMerge`, hash the active lockfile and compare against `node_modules/.fusion-install-marker` (written after each successful install). When they match, skip `pnpm install --frozen-lockfile` even if `package.json` is staged. Covers the common case where `package.json` changes but the lockfile doesn't, and amortizes install across auto-recovery re-enqueues that hit the same worktree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, TaskPriority } from "@fusion/core";
|
||||
import { COLUMNS } from "@fusion/core";
|
||||
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
@@ -188,12 +188,19 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
// Keep per-column array identities stable for unchanged columns so React.memo(Column)
|
||||
// can skip sibling rerenders during unrelated task updates.
|
||||
const tasksByColumn = useMemo(() => {
|
||||
const nextGrouped = Object.fromEntries(
|
||||
COLUMNS.map((column) => [column, [] as Task[]]),
|
||||
) as Record<ColumnType, Task[]>;
|
||||
const nextGrouped: Record<ColumnType, Task[]> = {
|
||||
triage: [],
|
||||
todo: [],
|
||||
"in-progress": [],
|
||||
"in-review": [],
|
||||
done: [],
|
||||
archived: [],
|
||||
};
|
||||
|
||||
for (const task of tasks) {
|
||||
nextGrouped[task.column].push(task);
|
||||
const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN;
|
||||
const bucket = nextGrouped[column] ?? nextGrouped[DEFAULT_COLUMN];
|
||||
bucket.push(task);
|
||||
}
|
||||
|
||||
const previousGrouped = tasksByColumnCacheRef.current;
|
||||
|
||||
@@ -2,7 +2,7 @@ import "./ListView.css";
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
|
||||
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
|
||||
import { TaskDetailContent } from "./TaskDetailModal";
|
||||
import type { ModelInfo, NodeInfo } from "../api";
|
||||
@@ -530,7 +530,10 @@ export function ListView({
|
||||
done: [],
|
||||
archived: []
|
||||
};
|
||||
sorted.forEach(task => groups[task.column].push(task));
|
||||
sorted.forEach((task) => {
|
||||
const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN;
|
||||
groups[column].push(task);
|
||||
});
|
||||
return groups;
|
||||
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
|
||||
|
||||
|
||||
@@ -79,6 +79,26 @@ describe("Board", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back malformed task columns to triage instead of crashing", () => {
|
||||
const malformedTask = {
|
||||
id: "FN-404",
|
||||
description: "Malformed",
|
||||
column: "impossible-column",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
} as unknown as Task;
|
||||
|
||||
expect(() => renderBoard({ tasks: [malformedTask] })).not.toThrow();
|
||||
|
||||
const triageTasks = JSON.parse(screen.getByTestId("column-triage").getAttribute("data-tasks") || "[]") as Task[];
|
||||
expect(triageTasks).toHaveLength(1);
|
||||
expect(triageTasks[0]?.id).toBe("FN-404");
|
||||
});
|
||||
|
||||
it("forwards board-level workflow name lookup to columns", async () => {
|
||||
renderBoard();
|
||||
|
||||
|
||||
@@ -157,6 +157,21 @@ describe("ListView", () => {
|
||||
expect(screen.getByText("View options")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back malformed task columns to Planning group instead of crashing", () => {
|
||||
const malformedTask = {
|
||||
...createMockTask({ id: "FN-404" }),
|
||||
column: "impossible-column",
|
||||
} as unknown as Task;
|
||||
|
||||
expect(() => renderListView({ tasks: [malformedTask] })).not.toThrow();
|
||||
expect(screen.getByText("FN-404")).toBeInTheDocument();
|
||||
|
||||
const planningSection = screen
|
||||
.getAllByRole("row")
|
||||
.find((row) => row.className.includes("list-section-header") && row.textContent?.includes("Planning"));
|
||||
expect(planningSection?.textContent).toContain("1");
|
||||
});
|
||||
|
||||
it("keeps view options collapsed by default on desktop", () => {
|
||||
renderListView({}, { openViewOptions: false });
|
||||
|
||||
|
||||
@@ -140,6 +140,22 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks[0].id).toBe("FN-001");
|
||||
});
|
||||
|
||||
it("normalizes invalid column values from initial fetch to triage", async () => {
|
||||
const malformedTask = {
|
||||
...createMockTask({ id: "FN-099" }),
|
||||
column: "unknown-column",
|
||||
} as unknown as Task;
|
||||
mockFetchTasks.mockResolvedValueOnce([malformedTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("triage");
|
||||
});
|
||||
|
||||
describe("SSE event: task:created", () => {
|
||||
it("adds new task to the list", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
@@ -158,6 +174,27 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0].id).toBe("FN-002");
|
||||
});
|
||||
|
||||
it("normalizes invalid column values from SSE created events", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const malformedTask = {
|
||||
...createMockTask({ id: "FN-003" }),
|
||||
column: "bad-column",
|
||||
} as unknown as Task;
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:created", malformedTask);
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0].column).toBe("triage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE event: task:moved", () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
||||
import { normalizeColumn } from "@fusion/core";
|
||||
import * as api from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
function normalizeTask(task: Task): Task {
|
||||
return {
|
||||
...task,
|
||||
column: normalizeColumn((task as Task & { column?: unknown }).column),
|
||||
dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
|
||||
steps: Array.isArray(task.steps) ? task.steps : [],
|
||||
log: Array.isArray((task as Task & { log?: unknown }).log)
|
||||
@@ -255,7 +257,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === normalizedTask.id ? { ...normalizedTask, column: to } : t
|
||||
t.id === normalizedTask.id ? { ...normalizedTask, column: normalizeColumn(to, normalizedTask.column) } : t
|
||||
)
|
||||
);
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user