feat(FN-3834): short-circuit no-op merges and auto-finalize review tasks
Merged FN-3834 to add a no-op merge recovery flow to the merger, including a branch-ahead detector, short-circuit logic to exclude no-op merges from the mergeable sweep, and finalization of no-op review tasks — backed by substantial test coverage across merger and self-healing modules. Fusion-Task-Id: FN-3834
This commit is contained in:
5
.changeset/FN-3834-merger-noop-shortcircuit.md
Normal file
5
.changeset/FN-3834-merger-noop-shortcircuit.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fixes a merger/self-healing recovery loop where in-review tasks with zero commits ahead of base were repeatedly re-enqueued forever. Fusion now detects deterministic no-op merge branches, marks them as no-op merge confirmed, and finalizes them to done instead of requeueing.
|
||||
5
.changeset/fn-3909-agent-card-buttons.md
Normal file
5
.changeset/fn-3909-agent-card-buttons.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Restore icon on the agent card "Details" button and only hide action labels in the split sidebar when buttons would not fit.
|
||||
@@ -1002,6 +1002,8 @@ export interface MergeDetails {
|
||||
mergeCommitMessage?: string;
|
||||
mergedAt?: string;
|
||||
mergeConfirmed?: boolean;
|
||||
noOpMerge?: boolean;
|
||||
noOpReason?: string;
|
||||
prNumber?: number;
|
||||
mergeTargetBranch?: string;
|
||||
mergeTargetSource?: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
|
||||
@@ -1096,7 +1098,7 @@ export interface TaskBranchContext {
|
||||
export interface Task {
|
||||
id: string;
|
||||
/** Immutable lineage identity used for durable commit/task attribution. */
|
||||
lineageId: string;
|
||||
lineageId?: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
/**
|
||||
@@ -2577,6 +2579,7 @@ export interface MergeResult extends MergeDetails {
|
||||
task: Task;
|
||||
branch: string;
|
||||
merged: boolean;
|
||||
noOp?: boolean;
|
||||
worktreeRemoved: boolean;
|
||||
branchDeleted: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -702,6 +702,8 @@
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
container-type: inline-size;
|
||||
container-name: agent-card-actions;
|
||||
}
|
||||
|
||||
.agent-card-actions .btn {
|
||||
@@ -718,8 +720,10 @@
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.agents-split-sidebar .agent-card-actions .agent-card-action-label {
|
||||
display: none;
|
||||
@container agent-card-actions (max-width: calc(var(--space-2xl) * 9)) {
|
||||
.agents-split-sidebar .agent-card-actions .agent-card-action-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-card-details-btn {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./AgentsView.css";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense, type CSSProperties } from "react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2 } from "lucide-react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Info } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
|
||||
@@ -1445,7 +1445,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
title={`View details for ${agent.name}`}
|
||||
aria-label={`View details for ${agent.name}`}
|
||||
>
|
||||
<ChevronRight size={14} /> <span className="agent-card-action-label">Details</span>
|
||||
<Info size={14} /> <span className="agent-card-action-label">Details</span>
|
||||
</button>
|
||||
{(agent.state === "idle" || agent.state === "paused") && (
|
||||
<button
|
||||
|
||||
@@ -658,6 +658,14 @@ describe("AgentsView", () => {
|
||||
expect(detailsButton.querySelector("svg")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides split-sidebar action labels only within an agent-card-actions container query", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expect(css).not.toContain(".agents-split-sidebar .agent-card-actions .agent-card-action-label {\n display: none;\n}");
|
||||
expect(css).toContain("@container agent-card-actions (max-width: calc(var(--space-2xl) * 9))");
|
||||
expect(css).toContain(".agents-split-sidebar .agent-card-actions .agent-card-action-label {\n display: none;\n }");
|
||||
});
|
||||
|
||||
it("opens matching detail view when clicking View Details button", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
|
||||
@@ -1084,6 +1084,44 @@ describe("aiMergeTask — merge-target branch resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiMergeTask — no-op short-circuit", () => {
|
||||
it("finalizes to done when branch has zero commits ahead of base", async () => {
|
||||
const store = createMockStore({
|
||||
id: "FN-3834-NOOP",
|
||||
branch: "fusion/fn-3834-noop",
|
||||
mergeDetails: { mergeTargetBranch: "main" },
|
||||
worktree: "/tmp/root/.worktrees/FN-3834-NOOP",
|
||||
});
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify") && cmdStr.includes("fusion/fn-3834-noop")) return Buffer.from("ok");
|
||||
if (cmdStr.includes("rev-parse --verify") && cmdStr.includes("main")) return Buffer.from("ok");
|
||||
if (cmdStr.includes("rev-list --count") && cmdStr.includes("main") && cmdStr.includes("fusion/fn-3834-noop")) return "0\n" as any;
|
||||
if (cmdStr.includes("git merge --squash")) {
|
||||
throw new Error("merge path should not run");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-3834-NOOP");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-3834-NOOP", "done");
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-3834-NOOP",
|
||||
expect.objectContaining({
|
||||
mergeDetails: expect.objectContaining({
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason: expect.stringContaining("main"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiMergeTask — empty squash merge (branch already merged via dep)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -68,7 +68,7 @@ vi.mock("../logger.js", () => ({
|
||||
createLogger: vi.fn((_name: string) => selfHealingLoggerMock),
|
||||
}));
|
||||
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
import { SelfHealingManager, isBranchAheadOfBase } from "../self-healing.js";
|
||||
import type { TaskStore, Settings, Task, AgentStore, Agent } from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
@@ -2360,6 +2360,232 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("finalizes no-op in-review tasks with zero commits ahead", async () => {
|
||||
const enqueueMerge = vi.fn();
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
enqueueMerge,
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("rev-parse --verify 'fusion/fn-500'")) return "ok" as any;
|
||||
if (cmd.includes("rev-parse --verify 'main'")) return "ok" as any;
|
||||
if (cmd.includes("rev-list --count 'main'..'fusion/fn-500'")) return "0\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "FN-500",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
worktree: "/tmp/test-project/.worktrees/fn-500",
|
||||
steps: [{ name: "Ship it", status: "done" }],
|
||||
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
||||
mergeDetails: undefined,
|
||||
log: [],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "FN-500",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
mergeRetries: 0,
|
||||
worktree: "/tmp/test-project/.worktrees/fn-500",
|
||||
steps: [{ name: "Ship it", status: "done" }],
|
||||
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
||||
mergeDetails: { mergeConfirmed: true, noOpMerge: true },
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const finalized = await managerWithRecovery.finalizeNoOpReviewTasks();
|
||||
const recovered = await managerWithRecovery.recoverMergeableReviewTasks();
|
||||
|
||||
expect(finalized).toBe(1);
|
||||
expect(recovered).toBe(0);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-500",
|
||||
expect.objectContaining({
|
||||
mergeDetails: expect.objectContaining({
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason: expect.stringContaining("main"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-500", "done");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-500",
|
||||
expect.stringContaining("Auto-finalized: branch has zero commits ahead of main"),
|
||||
);
|
||||
expect(enqueueMerge).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("does not finalize when branch is ahead by one or more commits", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("rev-parse --verify 'fusion/fn-501'")) return "ok" as any;
|
||||
if (cmd.includes("rev-parse --verify 'main'")) return "ok" as any;
|
||||
if (cmd.includes("rev-list --count 'main'..'fusion/fn-501'")) return "3\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-501",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
worktree: "/tmp/test-project/.worktrees/fn-501",
|
||||
steps: [{ name: "Ship it", status: "done" }],
|
||||
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
||||
mergeDetails: undefined,
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-501", "done");
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips finalize pass when autoMerge is disabled", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: false,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
|
||||
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("does not finalize no-op tasks when branch inspection errors", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("rev-parse --verify 'fusion/fn-502'")) return "ok" as any;
|
||||
if (cmd.includes("rev-parse --verify 'main'")) return "ok" as any;
|
||||
if (cmd.includes("rev-list --count 'main'..'fusion/fn-502'")) {
|
||||
throw new Error("git failed");
|
||||
}
|
||||
return "" as any;
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-502",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
worktree: "/tmp/test-project/.worktrees/fn-502",
|
||||
steps: [{ name: "Ship it", status: "done" }],
|
||||
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
||||
mergeDetails: undefined,
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-502", "done");
|
||||
expect(getSelfHealingLogger().warn).toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("does not re-enqueue tasks marked noOpMerge", async () => {
|
||||
const enqueueMerge = vi.fn();
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
enqueueMerge,
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-503",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
mergeRetries: 0,
|
||||
worktree: "/tmp/test-project/.worktrees/fn-503",
|
||||
steps: [{ name: "Ship it", status: "done" }],
|
||||
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
||||
mergeDetails: { noOpMerge: true },
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.recoverMergeableReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(enqueueMerge).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith(
|
||||
"FN-503",
|
||||
expect.stringContaining("re-enqueued"),
|
||||
);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("resolves ahead count via origin fallback", async () => {
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("rev-parse --verify 'fusion/fn-999'")) return "ok" as any;
|
||||
if (cmd.includes("rev-parse --verify 'release'")) throw new Error("missing local");
|
||||
if (cmd.includes("rev-parse --verify 'origin/release'")) return "ok" as any;
|
||||
if (cmd.includes("rev-list --count 'origin/release'..'fusion/fn-999'")) return "0\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const result = await isBranchAheadOfBase(
|
||||
{ id: "FN-999", branch: "fusion/fn-999" } as Task,
|
||||
"/tmp/test-project",
|
||||
"release",
|
||||
);
|
||||
|
||||
expect(result).toEqual({ aheadCount: 0, baseRef: "origin/release" });
|
||||
});
|
||||
|
||||
it("moves stale in-review tasks with incomplete steps back to todo for retry", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
@@ -4324,6 +4550,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
makeSlow("recoverStaleIncompleteReviewTasks");
|
||||
makeSlow("recoverInterruptedMergingTasks");
|
||||
makeSlow("recoverStaleMergingStatus");
|
||||
makeSlow("finalizeNoOpReviewTasks");
|
||||
makeSlow("recoverMergeableReviewTasks");
|
||||
makeSlow("recoverMergedReviewTasks");
|
||||
makeSlow("recoverStuckMergeDeadlocks");
|
||||
@@ -4352,6 +4579,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
"recoverStaleIncompleteReviewTasks",
|
||||
"recoverInterruptedMergingTasks",
|
||||
"recoverStaleMergingStatus",
|
||||
"finalizeNoOpReviewTasks",
|
||||
"recoverMergeableReviewTasks",
|
||||
"recoverMergedReviewTasks",
|
||||
"recoverStuckMergeDeadlocks",
|
||||
|
||||
@@ -4724,6 +4724,76 @@ export async function aiMergeTask(
|
||||
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
|
||||
}
|
||||
|
||||
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||||
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch || "main";
|
||||
const resolveAheadCount = async (): Promise<{ aheadCount: number; baseRef: string } | null> => {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${quoteArg(branch)}`, { cwd: rootDir, timeout: 30_000 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let baseRef = requestedBaseRef;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${quoteArg(baseRef)}`, { cwd: rootDir, timeout: 30_000 });
|
||||
} catch {
|
||||
const remoteRef = `origin/${requestedBaseRef}`;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${quoteArg(remoteRef)}`, { cwd: rootDir, timeout: 30_000 });
|
||||
baseRef = remoteRef;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --count ${quoteArg(baseRef)}..${quoteArg(branch)}`,
|
||||
{ cwd: rootDir, timeout: 30_000 },
|
||||
);
|
||||
const aheadCount = Number.parseInt(stdout.trim(), 10);
|
||||
if (!Number.isFinite(aheadCount)) {
|
||||
return null;
|
||||
}
|
||||
return { aheadCount, baseRef };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const aheadInfo = await resolveAheadCount();
|
||||
if (aheadInfo?.aheadCount === 0) {
|
||||
const noOpReason = `branch has zero commits ahead of ${aheadInfo.baseRef}`;
|
||||
const mergeDetails: MergeDetails = {
|
||||
...(task.mergeDetails || {}),
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason,
|
||||
mergedAt: new Date().toISOString(),
|
||||
prNumber: task.prInfo?.number,
|
||||
mergeTargetBranch: aheadInfo.baseRef,
|
||||
};
|
||||
await store.updateTask(taskId, { mergeDetails });
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Auto-finalized: ${noOpReason}; treating as no-op merge and moving to done`,
|
||||
);
|
||||
await store.moveTask(taskId, "done");
|
||||
return {
|
||||
task,
|
||||
branch,
|
||||
merged: true,
|
||||
noOp: true,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason,
|
||||
mergedAt: mergeDetails.mergedAt,
|
||||
mergeTargetBranch: aheadInfo.baseRef,
|
||||
};
|
||||
}
|
||||
|
||||
// Advisory: announce that rootDir is volatile until this merge finishes.
|
||||
// Dashboards / status lines / pre-Edit hooks can read this file to warn
|
||||
// devs that edits made now may end up in a race-rescue stash. Not a lock —
|
||||
@@ -4759,7 +4829,6 @@ export async function aiMergeTask(
|
||||
let resultForFinally: MergeResult | undefined;
|
||||
try {
|
||||
|
||||
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||||
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
|
||||
const worktreePath = task.worktree;
|
||||
const result: MergeResult = {
|
||||
@@ -7224,7 +7293,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
} else {
|
||||
// The agent committed. Idempotently ensure canonical task trailers are
|
||||
// present on HEAD for durable lineage attribution and fallback recovery.
|
||||
await ensureTaskTrailersOnHead(rootDir, task);
|
||||
await ensureTaskTrailersOnHead(rootDir, { id: taskId });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -160,6 +160,62 @@ function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export async function isBranchAheadOfBase(
|
||||
task: Task,
|
||||
rootDir: string,
|
||||
preferredBaseRef?: string,
|
||||
): Promise<{ aheadCount: number; baseRef: string } | null> {
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(branchName)}`, {
|
||||
cwd: rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedBaseRef = preferredBaseRef || task.mergeDetails?.mergeTargetBranch || "main";
|
||||
let resolvedBaseRef = requestedBaseRef;
|
||||
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(requestedBaseRef)}`, {
|
||||
cwd: rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
const remoteRef = `origin/${requestedBaseRef}`;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(remoteRef)}`, {
|
||||
cwd: rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
resolvedBaseRef = remoteRef;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --count ${shellQuote(resolvedBaseRef)}..${shellQuote(branchName)}`,
|
||||
{ cwd: rootDir, timeout: 30_000 },
|
||||
);
|
||||
const aheadCount = Number.parseInt(stdout.trim(), 10);
|
||||
if (!Number.isFinite(aheadCount)) {
|
||||
return null;
|
||||
}
|
||||
return { aheadCount, baseRef: resolvedBaseRef };
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(
|
||||
`Failed to compare ${branchName} against ${resolvedBaseRef} for ${task.id}: ${errorMessage}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseShortstat(output: string): Pick<LandedTaskCommit, "filesChanged" | "insertions" | "deletions"> {
|
||||
const normalized = output.trim().replace(/\n/g, " ");
|
||||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||||
@@ -878,6 +934,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() },
|
||||
{ name: "recover-done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata() },
|
||||
{ name: "recover-stale-merging-status", fn: () => this.recoverStaleMergingStatus() },
|
||||
{ name: "finalize-noop-review", fn: () => this.finalizeNoOpReviewTasks() },
|
||||
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
|
||||
{ name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
|
||||
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() },
|
||||
@@ -1193,6 +1250,65 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeNoOpReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (!settings.autoMerge) return 0;
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
!t.paused &&
|
||||
Boolean(t.worktree) &&
|
||||
t.mergeDetails?.mergeConfirmed !== true &&
|
||||
t.status !== "merging" &&
|
||||
t.status !== "merging-pr" &&
|
||||
t.status !== "awaiting-user-review" &&
|
||||
t.status !== "failed" &&
|
||||
getTaskMergeBlocker(t) === undefined,
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
const ahead = await this.isBranchAheadOfBase(task, task.mergeDetails?.mergeTargetBranch || "main");
|
||||
if (!ahead || ahead.aheadCount !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const noOpReason = `branch has zero commits ahead of ${ahead.baseRef}`;
|
||||
// Reaching in-review means executor/spec gates already passed. If there
|
||||
// are no commits ahead of base, treat this as a successful no-op merge.
|
||||
const mergeDetails: MergeDetails = {
|
||||
...(task.mergeDetails || {}),
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason,
|
||||
mergedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.store.updateTask(task.id, { mergeDetails });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-finalized: ${noOpReason}; treating as no-op merge and moving to done`,
|
||||
);
|
||||
await this.store.moveTask(task.id, "done");
|
||||
recovered++;
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} no-op review task(s) → done`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`No-op review finalization failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover `in-review` tasks that are fully mergeable but never had
|
||||
* `mergeTask()` invoked.
|
||||
@@ -1223,11 +1339,13 @@ export class SelfHealingManager {
|
||||
t.status !== "merging-pr" &&
|
||||
Boolean(t.worktree) &&
|
||||
t.mergeDetails?.mergeConfirmed !== true &&
|
||||
t.mergeDetails?.noOpMerge !== true &&
|
||||
!hasTerminalInvalidDoneTransition(t) &&
|
||||
// Mirror ProjectEngine.canMergeTask retry gate. If retries are already
|
||||
// exhausted, re-enqueueing here is a no-op and each recovery log write
|
||||
// refreshes updatedAt, preventing cooldown-based retries from ever
|
||||
// becoming eligible.
|
||||
// becoming eligible. Also skip tasks explicitly tagged as no-op merges
|
||||
// in case updateTask(moveTask) is briefly out-of-order during recovery.
|
||||
(t.mergeRetries ?? 0) < MAX_AUTO_MERGE_RETRIES &&
|
||||
getTaskMergeBlocker(t) === undefined,
|
||||
);
|
||||
@@ -2422,6 +2540,13 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async isBranchAheadOfBase(
|
||||
task: Task,
|
||||
baseRef?: string,
|
||||
): Promise<{ aheadCount: number; baseRef: string } | null> {
|
||||
return isBranchAheadOfBase(task, this.options.rootDir, baseRef);
|
||||
}
|
||||
|
||||
private async hasRecoverableGitWork(task: Task): Promise<boolean> {
|
||||
if (task.worktree && existsSync(task.worktree)) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user