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:
gsxdsm
2026-05-06 12:24:57 -07:00
parent 9087239078
commit cd845d39be
11 changed files with 233 additions and 16 deletions

View File

@@ -0,0 +1,18 @@
---
"@runfusion/fusion": patch
---
Reduce redundant test/build runs during merge verification:
- **Skip the verification re-run after a no-op in-merge fix.** When the fix
agent doesn't actually modify the working tree (compared via a git
`diff HEAD` + `status --porcelain` content fingerprint), there's nothing
new to verify. The merger now logs "fix agent made no changes — skipping
verification re-run" and records the attempt as failed without paying the
multi-minute test/build cost.
- **Skip `pnpm install --frozen-lockfile` when the lockfile hash hasn't
changed since the last successful install.** A `node_modules/.fusion-install-marker`
file records the lockfile SHA-256 after a successful install; subsequent
merge attempts in the same worktree skip install when the lockfile content
is unchanged, even when `package.json` is staged. Existing
`shouldSyncDependenciesForMerge` filtering still applies as a first gate.

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, PROJECT_AUTH_ROLES } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, PROJECT_AUTH_ROLES } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, ProjectAuthRole, ProjectAuthUser, ProjectAuthMembership, ProjectAuthProvider, ProjectAuthSession, ProjectAuthUserCreateInput, ProjectAuthMembershipCreateInput, ProjectAuthProviderCreateInput, ProjectAuthSessionCreateInput, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export * from "./mesh-replication-protocol.js";

View File

@@ -5,6 +5,16 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
export type Column = (typeof COLUMNS)[number];
export const DEFAULT_COLUMN: Column = "triage";
export function isColumn(value: unknown): value is Column {
return typeof value === "string" && (COLUMNS as readonly string[]).includes(value);
}
export function normalizeColumn(value: unknown, fallback: Column = DEFAULT_COLUMN): Column {
return isColumn(value) ? value : fallback;
}
/** Ordered task-priority levels for the core task domain contract. */
export const TASK_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export type TaskPriority = (typeof TASK_PRIORITIES)[number];

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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();

View File

@@ -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 });

View File

@@ -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", () => {

View File

@@ -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();

View File

@@ -26,7 +26,8 @@ export {
type VerificationResult,
} from "./verification-utils.js";
import { existsSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import {
getTaskMergeBlocker,
@@ -215,6 +216,40 @@ function getDependencySyncCommand(rootDir: string): string | null {
return null;
}
const INSTALL_MARKER_RELPATH = join("node_modules", ".fusion-install-marker");
const LOCKFILE_CANDIDATES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb", "bun.lock"];
function computeLockfileHash(rootDir: string): string | null {
for (const name of LOCKFILE_CANDIDATES) {
const p = join(rootDir, name);
if (existsSync(p)) {
try {
return createHash("sha256").update(readFileSync(p)).digest("hex");
} catch {
return null;
}
}
}
return null;
}
function readInstallMarker(rootDir: string): string | null {
try {
const value = readFileSync(join(rootDir, INSTALL_MARKER_RELPATH), "utf-8").trim();
return value || null;
} catch {
return null;
}
}
function writeInstallMarker(rootDir: string, hash: string): void {
try {
writeFileSync(join(rootDir, INSTALL_MARKER_RELPATH), hash);
} catch {
// Best-effort: a missing marker just means the next merge re-runs install.
}
}
async function syncDependenciesForMerge(
store: TaskStore,
rootDir: string,
@@ -224,6 +259,21 @@ async function syncDependenciesForMerge(
const installCommand = getDependencySyncCommand(rootDir);
if (!installCommand) return;
// Skip the install if node_modules is present and the lockfile content
// matches the hash recorded after the last successful install. Caller's
// shouldSyncDependenciesForMerge gate already filters most no-ops; this
// covers the case where package.json (but not the lockfile) is staged, and
// the case where multiple merge attempts hit the same worktree in a row.
const lockHash = computeLockfileHash(rootDir);
if (lockHash && hasInstallState(rootDir) && readInstallMarker(rootDir) === lockHash) {
mergerLog.log(`${taskId}: skipping dependency sync (lockfile unchanged since last install)`);
await store.logEntry(
taskId,
`Skipping dependency sync: lockfile hash matches last successful ${installCommand}`,
);
return;
}
throwIfAborted(signal, taskId);
mergerLog.log(`${taskId}: syncing dependencies before merge build verification`);
await store.logEntry(taskId, `Syncing dependencies before merge build verification: ${installCommand}`);
@@ -235,6 +285,7 @@ async function syncDependenciesForMerge(
timeout: 300_000,
});
throwIfAborted(signal, taskId);
if (lockHash) writeInstallMarker(rootDir, lockHash);
} catch (error: any) {
throwIfAborted(signal, taskId);
const details = error?.stderr || error?.stdout || error?.message || String(error);
@@ -475,6 +526,32 @@ export async function snapshotDirtyFiles(rootDir: string): Promise<Set<string>>
return paths;
}
/**
* Hash the working tree's dirty content (full diff against HEAD plus porcelain
* status). Returns "" on failure or when nothing is dirty. Used to detect
* whether an in-merge fix agent actually changed anything before paying for
* a verification re-run.
*/
async function gitDirtyFingerprint(rootDir: string): Promise<string> {
try {
const [diffOut, statusOut] = await Promise.all([
execFileAsync("git", ["diff", "HEAD"], {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 64 * 1024 * 1024,
}).then((r) => r.stdout, () => ""),
execFileAsync("git", ["status", "-z", "--porcelain"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
]);
if (!diffOut && !statusOut) return "";
return createHash("sha256").update(diffOut).update("\0").update(statusOut).digest("hex");
} catch {
return "";
}
}
function rethrowIfMergeAborted(error: unknown): void {
if (error instanceof Error && error.name === "MergeAbortedError") {
throw error;
@@ -698,6 +775,7 @@ async function attemptInMergeVerificationFix(
// Snapshot the working tree before doing anything so the diff reflects only
// what the fix agent touched, not pre-existing dirty state.
const preFixSnapshot = await snapshotDirtyFiles(rootDir);
const preFixFingerprint = await gitDirtyFingerprint(rootDir);
try {
mergerLog.log(`${taskId}: spawning in-merge verification fix agent`);
@@ -838,12 +916,39 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
// Compute which paths the fix agent introduced or modified, then
// accumulate them into the caller's mutable set.
const postFixSnapshot = await snapshotDirtyFiles(rootDir);
const newlyTouched: string[] = [];
for (const p of postFixSnapshot) {
if (!preFixSnapshot.has(p)) newlyTouched.push(p);
}
if (fixModifiedFiles) {
for (const p of postFixSnapshot) {
if (!preFixSnapshot.has(p)) {
fixModifiedFiles.add(p);
}
}
for (const p of newlyTouched) fixModifiedFiles.add(p);
}
// If the fix agent didn't actually edit anything, re-running the same
// failing verification can only yield the same failure — skip the
// multi-minute test/build cycle and report the attempt as unsuccessful.
// Use a git content fingerprint (diff + porcelain status) so we also
// catch in-place edits to already-dirty files, not just newly added
// paths. Only skip when we have a non-empty fingerprint to compare
// against; an empty pre-fingerprint means the snapshot tool failed and
// we should fall back to actually re-running verification.
const postFixFingerprint = await gitDirtyFingerprint(rootDir);
const fingerprintsMatch =
preFixFingerprint.length > 0 && preFixFingerprint === postFixFingerprint;
if (newlyTouched.length === 0 && fingerprintsMatch) {
mergerLog.warn(`${taskId}: in-merge fix agent made no changes — skipping verification re-run`);
await store.logEntry(
taskId,
`In-merge fix agent made no changes — skipping verification re-run (attempt ${fixAttemptNumber ?? "unknown"})`,
);
await store.appendAgentLog(
taskId,
`Fix agent made no changes — skipping verification re-run`,
"text",
undefined,
"merger",
);
return false;
}
// Re-run deterministic verification command after the fix attempt.

View File

@@ -241,4 +241,4 @@ if (args.includes("--before")) {
recordBaseline();
} else {
checkAgainstBaseline();
}
}