feat(FN-3865): complete Steps 3-6 — recover already-merged review tasks
This commit is contained in:
5
.changeset/fn-3864-recover-already-merged-review.md
Normal file
5
.changeset/fn-3864-recover-already-merged-review.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add `recoverAlreadyMergedReviewTasks()` self-healing sweep to recover phantom-merge-guard false positives. Detects tasks whose content already landed on the integration branch (via Fusion-Task-Id trailer, branch ancestry, or git patch-id walk) and reconciles them to `done` with proper merge metadata.
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
type TaskMap = Map<string, Task & { comments?: string[] }>;
|
||||
|
||||
function createStore(tasks: TaskMap, settings: Partial<Settings> = {}): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
const mergedSettings: Settings = {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maintenanceIntervalMs: 0,
|
||||
taskStuckTimeoutMs: 60_000,
|
||||
autoMerge: false,
|
||||
...settings,
|
||||
} as Settings;
|
||||
|
||||
const store = Object.assign(emitter, {
|
||||
getSettings: vi.fn(async () => mergedSettings),
|
||||
listTasks: vi.fn(async ({ column, includeArchived }: { column?: string; includeArchived?: boolean } = {}) => {
|
||||
const values = [...tasks.values()];
|
||||
return values.filter((task) => {
|
||||
if (!includeArchived && task.column === "archived") return false;
|
||||
if (column && task.column !== column) return false;
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
updateTask: vi.fn(async (id: string, updates: Partial<Task>) => {
|
||||
const current = tasks.get(id)!;
|
||||
tasks.set(id, { ...current, ...updates, updatedAt: new Date().toISOString() });
|
||||
return tasks.get(id);
|
||||
}),
|
||||
moveTask: vi.fn(async (id: string, column: Task["column"]) => {
|
||||
const current = tasks.get(id)!;
|
||||
tasks.set(id, { ...current, column, columnMovedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
||||
}),
|
||||
logEntry: vi.fn(async (id: string, message: string) => {
|
||||
const current = tasks.get(id)!;
|
||||
const log = current.log ?? [];
|
||||
tasks.set(id, { ...current, log: [...log, { timestamp: new Date().toISOString(), action: message }] as any });
|
||||
}),
|
||||
walCheckpoint: vi.fn(() => ({ busy: 0, log: 0, checkpointed: 0 })),
|
||||
archiveTaskAndCleanup: vi.fn(async () => ({})),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
|
||||
getTask: vi.fn(async (id: string) => tasks.get(id)),
|
||||
updateSettings: vi.fn(async () => mergedSettings),
|
||||
mergeTask: vi.fn(async () => undefined),
|
||||
getRootDir: vi.fn(() => ""),
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", () => {
|
||||
const repos: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repo of repos.splice(0)) {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setupRepo(): string {
|
||||
const repo = mkdtempSync(path.join(os.tmpdir(), "fn-3865-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
return repo;
|
||||
}
|
||||
|
||||
it("recovers via trailer match and removes worktree", async () => {
|
||||
const repo = setupRepo();
|
||||
mkdirSync(path.join(repo, "src"), { recursive: true });
|
||||
writeFileSync(path.join(repo, "src", "file.txt"), "trailer\n", "utf-8");
|
||||
git(repo, "git add src/file.txt && git commit -m 'feat: landed' -m 'Fusion-Task-Id: FN-TEST-1'");
|
||||
const landedSha = git(repo, "git rev-parse HEAD");
|
||||
|
||||
const worktreePath = path.join(repo, ".worktrees", "fn-test-1");
|
||||
mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||
git(repo, `git worktree add ${JSON.stringify(worktreePath)} -b fusion/fn-test-1`);
|
||||
|
||||
const tasks: TaskMap = new Map([
|
||||
["FN-TEST-1", { id: "FN-TEST-1", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-test-1", worktree: worktreePath, steps: [], log: [], updatedAt: new Date().toISOString() } as Task],
|
||||
]);
|
||||
const store = createStore(tasks);
|
||||
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
|
||||
await (manager as any).runMaintenance();
|
||||
|
||||
const task = tasks.get("FN-TEST-1")!;
|
||||
expect(task.column).toBe("done");
|
||||
expect(task.status).toBeNull();
|
||||
expect(task.mergeRetries).toBe(0);
|
||||
expect(task.mergeDetails?.commitSha).toBe(landedSha);
|
||||
expect(task.mergeDetails?.strategy).toBe("squash");
|
||||
expect(task.mergeDetails?.branch).toBe("main");
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(git(repo, "git worktree list")).not.toContain(worktreePath);
|
||||
});
|
||||
|
||||
it("recovers via patch-id fallback", async () => {
|
||||
const repo = setupRepo();
|
||||
git(repo, "git checkout -b fusion/fn-test-2");
|
||||
mkdirSync(path.join(repo, "src"), { recursive: true });
|
||||
writeFileSync(path.join(repo, "src", "patch.txt"), "patch-a\n", "utf-8");
|
||||
git(repo, "git add src/patch.txt && git commit -m 'task branch commit'");
|
||||
const branchTip = git(repo, "git rev-parse HEAD");
|
||||
git(repo, "git checkout main");
|
||||
mkdirSync(path.join(repo, "src"), { recursive: true });
|
||||
writeFileSync(path.join(repo, "src", "patch.txt"), "patch-a\n", "utf-8");
|
||||
git(repo, "git add src/patch.txt && git commit -m 'land equivalent change'");
|
||||
const landedSha = git(repo, "git rev-parse HEAD");
|
||||
|
||||
const worktreePath = path.join(repo, ".worktrees", "fn-test-2");
|
||||
mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||
git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-test-2`);
|
||||
|
||||
const tasks: TaskMap = new Map([
|
||||
["FN-TEST-2", { id: "FN-TEST-2", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-test-2", baseCommitSha: git(repo, "git merge-base main fusion/fn-test-2"), worktree: worktreePath, steps: [], log: [], updatedAt: new Date().toISOString() } as Task],
|
||||
]);
|
||||
const store = createStore(tasks);
|
||||
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
|
||||
expect(branchTip).toBeTruthy();
|
||||
await (manager as any).runMaintenance();
|
||||
|
||||
const task = tasks.get("FN-TEST-2")!;
|
||||
expect(task.column).toBe("done");
|
||||
expect(task.mergeDetails?.commitSha).toBe(landedSha);
|
||||
});
|
||||
|
||||
it("does nothing when no match exists", async () => {
|
||||
const repo = setupRepo();
|
||||
git(repo, "git checkout -b fusion/fn-test-3");
|
||||
mkdirSync(path.join(repo, "src"), { recursive: true });
|
||||
writeFileSync(path.join(repo, "src", "no-match.txt"), "branch-only\n", "utf-8");
|
||||
git(repo, "git add src/no-match.txt && git commit -m 'branch only'");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const worktreePath = path.join(repo, ".worktrees", "fn-test-3");
|
||||
mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||
git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-test-3`);
|
||||
|
||||
const tasks: TaskMap = new Map([
|
||||
["FN-TEST-3", { id: "FN-TEST-3", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-test-3", worktree: worktreePath, steps: [], log: [], updatedAt: new Date().toISOString() } as Task],
|
||||
]);
|
||||
const store = createStore(tasks);
|
||||
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
|
||||
await (manager as any).runMaintenance();
|
||||
|
||||
const task = tasks.get("FN-TEST-3")!;
|
||||
expect(task.column).toBe("in-review");
|
||||
expect(task.status).toBe("failed");
|
||||
expect(task.mergeRetries).toBe(3);
|
||||
expect(existsSync(worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent across two maintenance passes", async () => {
|
||||
const repo = setupRepo();
|
||||
mkdirSync(path.join(repo, "src"), { recursive: true });
|
||||
writeFileSync(path.join(repo, "src", "idempotent.txt"), "same\n", "utf-8");
|
||||
git(repo, "git add src/idempotent.txt && git commit -m 'feat: done' -m 'Fusion-Task-Id: FN-TEST-4'");
|
||||
|
||||
const worktreePath = path.join(repo, ".worktrees", "fn-test-4");
|
||||
mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||
git(repo, `git worktree add ${JSON.stringify(worktreePath)} -b fusion/fn-test-4`);
|
||||
|
||||
const tasks: TaskMap = new Map([
|
||||
["FN-TEST-4", { id: "FN-TEST-4", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-test-4", worktree: worktreePath, steps: [], log: [], updatedAt: new Date().toISOString() } as Task],
|
||||
]);
|
||||
const store = createStore(tasks);
|
||||
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
|
||||
await (manager as any).runMaintenance();
|
||||
const firstRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("phantom-merge-guard false positive")).length;
|
||||
await (manager as any).runMaintenance();
|
||||
|
||||
const secondRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("phantom-merge-guard false positive")).length;
|
||||
expect(firstRecoveryLogs).toBe(1);
|
||||
expect(secondRecoveryLogs).toBe(1);
|
||||
});
|
||||
|
||||
it("short-circuits when paused", async () => {
|
||||
const repo = setupRepo();
|
||||
const tasks: TaskMap = new Map([
|
||||
["FN-TEST-5", { id: "FN-TEST-5", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-test-5", steps: [], log: [], updatedAt: new Date().toISOString() } as Task],
|
||||
]);
|
||||
|
||||
const globalPausedStore = createStore(tasks, { globalPause: true, enginePaused: false });
|
||||
const globalPausedManager = new SelfHealingManager(globalPausedStore, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
await globalPausedManager.recoverAlreadyMergedReviewTasks();
|
||||
expect(globalPausedStore.listTasks).not.toHaveBeenCalled();
|
||||
|
||||
const enginePausedStore = createStore(tasks, { globalPause: false, enginePaused: true });
|
||||
const enginePausedManager = new SelfHealingManager(enginePausedStore, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
await enginePausedManager.recoverAlreadyMergedReviewTasks();
|
||||
expect(enginePausedStore.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2365,6 +2365,119 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverAlreadyMergedReviewTasks", () => {
|
||||
it("short-circuits when globalPause or enginePaused is active", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: true, enginePaused: false });
|
||||
|
||||
const pausedResult = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
expect(pausedResult).toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: true });
|
||||
const enginePausedResult = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
expect(enginePausedResult).toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("filters out non-candidates", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getExecutingTaskIds: () => new Set(["FN-executing"]),
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "FN-ok-status", column: "in-review", paused: false, status: null, mergeRetries: 3, mergeDetails: undefined, log: [] },
|
||||
{ id: "FN-low-retries", column: "in-review", paused: false, status: "failed", mergeRetries: 2, mergeDetails: undefined, log: [] },
|
||||
{ id: "FN-paused", column: "in-review", paused: true, status: "failed", mergeRetries: 3, mergeDetails: undefined, log: [] },
|
||||
{ id: "FN-executing", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, log: [] },
|
||||
{ id: "FN-confirmed", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: { mergeConfirmed: true }, log: [] },
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("leaves tasks untouched when no landed commit is detected", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "FN-1", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, branch: "fusion/fn-1", log: [] },
|
||||
]);
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("missing branch");
|
||||
});
|
||||
|
||||
const result = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("isolates per-task failures and still recovers later candidates", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "FN-throw", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, baseBranch: "main", branch: "fusion/fn-throw", worktree: "/tmp/wt1", log: [] },
|
||||
{ id: "FN-hit", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, baseBranch: "main", branch: "fusion/fn-hit", worktree: "/tmp/wt2", log: [] },
|
||||
]);
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedExecSync.mockImplementation((command: string | Buffer) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("Fusion-Task-Id: FN-throw")) throw new Error("trailer fail");
|
||||
if (cmd.includes("rev-parse --verify") && cmd.includes("fusion/fn-throw")) throw new Error("rev fail");
|
||||
if (cmd.includes("Fusion-Task-Id: FN-hit")) return "abc123\n" as any;
|
||||
if (cmd.includes("rev-parse --verify") && cmd.includes("fusion/fn-hit")) return "tip-hit\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const result = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-hit", expect.objectContaining({ column: "done" }));
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-throw", expect.anything());
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("is idempotent across repeated sweeps", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "FN-1", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, baseBranch: "main", branch: "fusion/fn-1", worktree: "/tmp/wt", log: [] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "FN-1", column: "done", paused: false, status: null, mergeRetries: 0, mergeDetails: { mergeConfirmed: true }, baseBranch: "main", log: [] },
|
||||
]);
|
||||
mockedExecSync.mockImplementation((command: string | Buffer) => {
|
||||
if (String(command).includes("Fusion-Task-Id: FN-1")) return "abc123\n" as any;
|
||||
return "tip\n" as any;
|
||||
});
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const first = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
const second = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
|
||||
|
||||
expect(first).toBe(1);
|
||||
expect(second).toBe(0);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverReviewTasksWithFailedPreMergeSteps", () => {
|
||||
const baseTask = {
|
||||
id: "FN-1572",
|
||||
|
||||
@@ -832,6 +832,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-stale-merging-status", fn: () => this.recoverStaleMergingStatus() },
|
||||
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
|
||||
{ name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
|
||||
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() },
|
||||
{ name: "recover-misclassified-failures", fn: () => this.recoverMisclassifiedFailures() },
|
||||
{ name: "recover-no-progress-no-task-done", fn: () => this.recoverNoProgressNoTaskDoneFailures() },
|
||||
{ name: "recover-partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures() },
|
||||
@@ -1603,6 +1604,99 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover retry-exhausted failed review tasks whose content already landed on
|
||||
* the integration branch via a non-canonical merge lineage.
|
||||
*
|
||||
* Candidate filter:
|
||||
* - `column === "in-review"`
|
||||
* - not paused
|
||||
* - `status === "failed"`
|
||||
* - `(mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES`
|
||||
* - `mergeDetails.mergeConfirmed !== true`
|
||||
* - not actively executing
|
||||
*
|
||||
* Detection order (first match wins):
|
||||
* 1. Fusion-Task-Id trailer lookup on the base branch
|
||||
* 2. Task branch ancestry + task-id grep on first-parent base lineage
|
||||
* 3. Patch-id match between task branch diff and recent base-branch commits
|
||||
*
|
||||
* Idempotency: recovered tasks are moved to `done`, status/error are cleared,
|
||||
* and mergeRetries reset to 0, so subsequent sweeps will not match them.
|
||||
*/
|
||||
async recoverAlreadyMergedReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
!task.paused &&
|
||||
task.status === "failed" &&
|
||||
(task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES &&
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
!executingIds.has(task.id),
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
try {
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||
if (!baseBranch) continue;
|
||||
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
taskId: task.id,
|
||||
repoDir: this.options.rootDir,
|
||||
baseBranch,
|
||||
taskBranch: task.branch,
|
||||
baseCommitSha: task.baseCommitSha,
|
||||
});
|
||||
if (!landed) continue;
|
||||
|
||||
const mergeDetails: MergeDetails = {
|
||||
commitSha: landed.sha,
|
||||
strategy: "squash",
|
||||
branch: baseBranch,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
};
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
column: "done",
|
||||
status: null,
|
||||
error: null,
|
||||
mergeRetries: 0,
|
||||
mergeDetails,
|
||||
});
|
||||
await this.store.moveTask(task.id, "done");
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-recovered: phantom-merge-guard false positive — content found on ${baseBranch} at ${landed.sha.slice(0, 8)} via ${landed.strategy}`,
|
||||
);
|
||||
await this.cleanupWorktreeOnly(task);
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`recoverAlreadyMergedReviewTasks: failed for ${task.id}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} already-merged retry-exhausted review task(s) → done`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Already-merged review recovery failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover tasks in `in-review` marked as `failed` where all steps are
|
||||
* actually done. This catches the case where an agent completed all work
|
||||
|
||||
Reference in New Issue
Block a user