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;
|
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;
|
||||||
|
|||||||
@@ -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,8 +720,10 @@
|
|||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agents-split-sidebar .agent-card-actions .agent-card-action-label {
|
@container agent-card-actions (max-width: calc(var(--space-2xl) * 9)) {
|
||||||
display: none;
|
.agents-split-sidebar .agent-card-actions .agent-card-action-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-card-details-btn {
|
.agent-card-details-btn {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user