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:
18
.changeset/merger-skip-redundant-verification.md
Normal file
18
.changeset/merger-skip-redundant-verification.md
Normal 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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -241,4 +241,4 @@ if (args.includes("--before")) {
|
||||
recordBaseline();
|
||||
} else {
|
||||
checkAgainstBaseline();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user