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:
Fusion
2026-05-11 03:44:10 -07:00
committed by gsxdsm
parent 6f2e8c4fc9
commit d6da4ebf8d
10 changed files with 494 additions and 9 deletions

View 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.

View 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.

View File

@@ -1002,6 +1002,8 @@ export interface MergeDetails {
mergeCommitMessage?: string; mergeCommitMessage?: string;
mergedAt?: string; mergedAt?: string;
mergeConfirmed?: boolean; mergeConfirmed?: boolean;
noOpMerge?: boolean;
noOpReason?: string;
prNumber?: number; prNumber?: number;
mergeTargetBranch?: string; mergeTargetBranch?: string;
mergeTargetSource?: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main"; mergeTargetSource?: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
@@ -1096,7 +1098,7 @@ export interface TaskBranchContext {
export interface Task { export interface Task {
id: string; id: string;
/** Immutable lineage identity used for durable commit/task attribution. */ /** Immutable lineage identity used for durable commit/task attribution. */
lineageId: string; lineageId?: string;
title?: string; title?: string;
description: string; description: string;
/** /**
@@ -2577,6 +2579,7 @@ export interface MergeResult extends MergeDetails {
task: Task; task: Task;
branch: string; branch: string;
merged: boolean; merged: boolean;
noOp?: boolean;
worktreeRemoved: boolean; worktreeRemoved: boolean;
branchDeleted: boolean; branchDeleted: boolean;
error?: string; error?: string;

View File

@@ -702,6 +702,8 @@
gap: var(--space-sm); gap: var(--space-sm);
align-items: center; align-items: center;
flex-wrap: nowrap; flex-wrap: nowrap;
container-type: inline-size;
container-name: agent-card-actions;
} }
.agent-card-actions .btn { .agent-card-actions .btn {
@@ -718,9 +720,11 @@
padding: var(--space-xs) var(--space-sm); padding: var(--space-xs) var(--space-sm);
} }
@container agent-card-actions (max-width: calc(var(--space-2xl) * 9)) {
.agents-split-sidebar .agent-card-actions .agent-card-action-label { .agents-split-sidebar .agent-card-actions .agent-card-action-label {
display: none; display: none;
} }
}
.agent-card-details-btn { .agent-card-details-btn {
margin-left: auto; margin-left: auto;

View File

@@ -1,6 +1,6 @@
import "./AgentsView.css"; import "./AgentsView.css";
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense, type CSSProperties } from "react"; 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 type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } 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}`} title={`View details for ${agent.name}`}
aria-label={`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> </button>
{(agent.state === "idle" || agent.state === "paused") && ( {(agent.state === "idle" || agent.state === "paused") && (
<button <button

View File

@@ -658,6 +658,14 @@ describe("AgentsView", () => {
expect(detailsButton.querySelector("svg")).toBeTruthy(); 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 () => { it("opens matching detail view when clicking View Details button", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);

View File

@@ -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)", () => { describe("aiMergeTask — empty squash merge (branch already merged via dep)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -68,7 +68,7 @@ vi.mock("../logger.js", () => ({
createLogger: vi.fn((_name: string) => selfHealingLoggerMock), 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 type { TaskStore, Settings, Task, AgentStore, Agent } from "@fusion/core";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
@@ -2360,6 +2360,232 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop(); 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 () => { it("moves stale in-review tasks with incomplete steps back to todo for retry", async () => {
const managerWithRecovery = new SelfHealingManager(store, { const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project", rootDir: "/tmp/test-project",
@@ -4324,6 +4550,7 @@ describe("maintenance cycle concurrency", () => {
makeSlow("recoverStaleIncompleteReviewTasks"); makeSlow("recoverStaleIncompleteReviewTasks");
makeSlow("recoverInterruptedMergingTasks"); makeSlow("recoverInterruptedMergingTasks");
makeSlow("recoverStaleMergingStatus"); makeSlow("recoverStaleMergingStatus");
makeSlow("finalizeNoOpReviewTasks");
makeSlow("recoverMergeableReviewTasks"); makeSlow("recoverMergeableReviewTasks");
makeSlow("recoverMergedReviewTasks"); makeSlow("recoverMergedReviewTasks");
makeSlow("recoverStuckMergeDeadlocks"); makeSlow("recoverStuckMergeDeadlocks");
@@ -4352,6 +4579,7 @@ describe("maintenance cycle concurrency", () => {
"recoverStaleIncompleteReviewTasks", "recoverStaleIncompleteReviewTasks",
"recoverInterruptedMergingTasks", "recoverInterruptedMergingTasks",
"recoverStaleMergingStatus", "recoverStaleMergingStatus",
"finalizeNoOpReviewTasks",
"recoverMergeableReviewTasks", "recoverMergeableReviewTasks",
"recoverMergedReviewTasks", "recoverMergedReviewTasks",
"recoverStuckMergeDeadlocks", "recoverStuckMergeDeadlocks",

View File

@@ -4724,6 +4724,76 @@ export async function aiMergeTask(
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`); 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. // Advisory: announce that rootDir is volatile until this merge finishes.
// Dashboards / status lines / pre-Edit hooks can read this file to warn // 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 — // 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; let resultForFinally: MergeResult | undefined;
try { try {
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue); const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
const worktreePath = task.worktree; const worktreePath = task.worktree;
const result: MergeResult = { const result: MergeResult = {
@@ -7224,7 +7293,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
} else { } else {
// The agent committed. Idempotently ensure canonical task trailers are // The agent committed. Idempotently ensure canonical task trailers are
// present on HEAD for durable lineage attribution and fallback recovery. // present on HEAD for durable lineage attribution and fallback recovery.
await ensureTaskTrailersOnHead(rootDir, task); await ensureTaskTrailersOnHead(rootDir, { id: taskId });
} }
return { success: true }; return { success: true };

View File

@@ -160,6 +160,62 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`; 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"> { function parseShortstat(output: string): Pick<LandedTaskCommit, "filesChanged" | "insertions" | "deletions"> {
const normalized = output.trim().replace(/\n/g, " "); const normalized = output.trim().replace(/\n/g, " ");
const filesMatch = normalized.match(/(\d+) files? changed/); const filesMatch = normalized.match(/(\d+) files? changed/);
@@ -878,6 +934,7 @@ export class SelfHealingManager {
{ name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() }, { name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() },
{ name: "recover-done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata() }, { name: "recover-done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata() },
{ name: "recover-stale-merging-status", fn: () => this.recoverStaleMergingStatus() }, { name: "recover-stale-merging-status", fn: () => this.recoverStaleMergingStatus() },
{ name: "finalize-noop-review", fn: () => this.finalizeNoOpReviewTasks() },
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() }, { name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
{ name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() }, { name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() }, { 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 * Recover `in-review` tasks that are fully mergeable but never had
* `mergeTask()` invoked. * `mergeTask()` invoked.
@@ -1223,11 +1339,13 @@ export class SelfHealingManager {
t.status !== "merging-pr" && t.status !== "merging-pr" &&
Boolean(t.worktree) && Boolean(t.worktree) &&
t.mergeDetails?.mergeConfirmed !== true && t.mergeDetails?.mergeConfirmed !== true &&
t.mergeDetails?.noOpMerge !== true &&
!hasTerminalInvalidDoneTransition(t) && !hasTerminalInvalidDoneTransition(t) &&
// Mirror ProjectEngine.canMergeTask retry gate. If retries are already // Mirror ProjectEngine.canMergeTask retry gate. If retries are already
// exhausted, re-enqueueing here is a no-op and each recovery log write // exhausted, re-enqueueing here is a no-op and each recovery log write
// refreshes updatedAt, preventing cooldown-based retries from ever // 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 && (t.mergeRetries ?? 0) < MAX_AUTO_MERGE_RETRIES &&
getTaskMergeBlocker(t) === undefined, 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> { private async hasRecoverableGitWork(task: Task): Promise<boolean> {
if (task.worktree && existsSync(task.worktree)) { if (task.worktree && existsSync(task.worktree)) {
try { try {