feat(FN-4967): merge fusion/fn-4967
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
|
||||
describe("FN-4967: self-healing multi-pr conflict reclaim", () => {
|
||||
function createManager(tasks: any[]) {
|
||||
const store = {
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
||||
} as any;
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp" } as any);
|
||||
return { manager, store };
|
||||
}
|
||||
|
||||
it("filters out tasks with no conflicting PRs", async () => {
|
||||
const { manager } = createManager([
|
||||
{ id: "FN-1", prInfo: { number: 1, mergeable: "clean" } },
|
||||
{ id: "FN-2", prInfos: [{ number: 2, mergeable: "unknown" }] },
|
||||
]);
|
||||
const reclaimSpy = vi.spyOn(manager, "reclaimPrConflictForTask").mockResolvedValue({ outcome: "skipped", reason: "no-conflicting-pr" });
|
||||
|
||||
const reclaimed = await manager.reclaimPrConflicts();
|
||||
|
||||
expect(reclaimSpy).not.toHaveBeenCalled();
|
||||
expect(reclaimed).toBe(0);
|
||||
});
|
||||
|
||||
it("runs reclaim for tasks where any linked PR is conflicting", async () => {
|
||||
const { manager } = createManager([
|
||||
{ id: "FN-1", prInfos: [{ number: 10, mergeable: "clean" }, { number: 11, mergeable: "conflicting" }] },
|
||||
{ id: "FN-2", prInfo: { number: 12, mergeable: "conflicting" } },
|
||||
]);
|
||||
const reclaimSpy = vi.spyOn(manager, "reclaimPrConflictForTask")
|
||||
.mockResolvedValueOnce({ outcome: "reclaimed" })
|
||||
.mockResolvedValueOnce({ outcome: "skipped", reason: "active-session" });
|
||||
|
||||
const reclaimed = await manager.reclaimPrConflicts();
|
||||
|
||||
expect(reclaimSpy).toHaveBeenCalledTimes(2);
|
||||
expect(reclaimSpy).toHaveBeenNthCalledWith(1, "FN-1");
|
||||
expect(reclaimSpy).toHaveBeenNthCalledWith(2, "FN-2");
|
||||
expect(reclaimed).toBe(1);
|
||||
});
|
||||
|
||||
it("returns skipped when no conflicting PR is linked", async () => {
|
||||
const task = { id: "FN-3", prInfos: [{ number: 31, mergeable: "clean" }] };
|
||||
const { manager, store } = createManager([]);
|
||||
store.getTask.mockResolvedValue(task);
|
||||
|
||||
const result = await manager.reclaimPrConflictForTask("FN-3");
|
||||
|
||||
expect(result).toEqual({ outcome: "skipped", reason: "no-conflicting-pr" });
|
||||
});
|
||||
|
||||
it("includes per-pr outcomes for multiple conflicting PRs", async () => {
|
||||
const task = {
|
||||
id: "FN-4",
|
||||
prInfos: [{ number: 41, mergeable: "conflicting" }, { number: 42, mergeable: "conflicting" }],
|
||||
};
|
||||
const { manager, store } = createManager([]);
|
||||
store.getTask.mockResolvedValue(task);
|
||||
|
||||
const result = await manager.reclaimPrConflictForTask("FN-4");
|
||||
|
||||
expect(result.outcome).toBe("skipped");
|
||||
expect(result.reason).toBe("missing-branch-or-worktree");
|
||||
expect(result.perPr).toEqual([
|
||||
{ number: 41, outcome: "skipped", reason: "missing-branch-or-worktree" },
|
||||
{ number: 42, outcome: "skipped", reason: "missing-branch-or-worktree" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { RemovalReason, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
@@ -1508,7 +1508,10 @@ export class SelfHealingManager {
|
||||
|
||||
async reclaimPrConflicts(): Promise<number> {
|
||||
const tasks = await this.store.listTasks({ slim: true });
|
||||
const candidates = tasks.filter((task) => task.prInfo?.mergeable === "conflicting");
|
||||
const candidates = tasks.filter((task) => {
|
||||
const prList = task.prInfos ?? (task.prInfo ? [task.prInfo] : []);
|
||||
return prList.some((pr) => pr.mergeable === "conflicting");
|
||||
});
|
||||
let reclaimed = 0;
|
||||
for (const task of candidates) {
|
||||
const result = await this.reclaimPrConflictForTask(task.id);
|
||||
@@ -1519,18 +1522,31 @@ export class SelfHealingManager {
|
||||
return reclaimed;
|
||||
}
|
||||
|
||||
async reclaimPrConflictForTask(taskId: string): Promise<{ outcome: "reclaimed" | "stale-resolved" | "tip-already-merged" | "paused-unrecoverable" | "skipped"; reason?: string }> {
|
||||
async reclaimPrConflictForTask(taskId: string): Promise<{ outcome: "reclaimed" | "stale-resolved" | "tip-already-merged" | "paused-unrecoverable" | "skipped"; reason?: string; perPr?: Array<{ number: number; outcome: "reclaimed" | "stale-resolved" | "tip-already-merged" | "paused-unrecoverable" | "skipped"; reason?: string }> }> {
|
||||
const task = await this.store.getTask(taskId);
|
||||
if (!task) return { outcome: "skipped", reason: "task-not-found" };
|
||||
const conflictingPrs = (task.prInfos ?? (task.prInfo ? [task.prInfo] : [])).filter((pr) => pr.mergeable === "conflicting");
|
||||
if (conflictingPrs.length === 0) {
|
||||
return { outcome: "skipped", reason: "no-conflicting-pr" };
|
||||
}
|
||||
const withPerPr = (result: { outcome: "reclaimed" | "stale-resolved" | "tip-already-merged" | "paused-unrecoverable" | "skipped"; reason?: string }) => {
|
||||
if (conflictingPrs.length <= 1) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
perPr: conflictingPrs.map((pr) => ({ number: pr.number, outcome: result.outcome, reason: result.reason })),
|
||||
};
|
||||
};
|
||||
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return { outcome: "skipped", reason: "engine-paused" };
|
||||
if (!task.branch || !task.worktree) return { outcome: "skipped", reason: "missing-branch-or-worktree" };
|
||||
if (task.userPaused) return { outcome: "skipped", reason: "user-paused" };
|
||||
if (task.checkedOutBy) return { outcome: "skipped", reason: "checked-out" };
|
||||
if (task.pausedReason === "worktrunk_operation_failed") return { outcome: "skipped", reason: "worktrunk-paused" };
|
||||
if (activeSessionRegistry.isPathActive(task.worktree)) return { outcome: "skipped", reason: "active-session" };
|
||||
if (!await isUsableTaskWorktree(this.options.rootDir, task.worktree)) return { outcome: "skipped", reason: "unusable-worktree" };
|
||||
if (settings.globalPause || settings.enginePaused) return withPerPr({ outcome: "skipped", reason: "engine-paused" });
|
||||
if (!task.branch || !task.worktree) return withPerPr({ outcome: "skipped", reason: "missing-branch-or-worktree" });
|
||||
if (task.userPaused) return withPerPr({ outcome: "skipped", reason: "user-paused" });
|
||||
if (task.checkedOutBy) return withPerPr({ outcome: "skipped", reason: "checked-out" });
|
||||
if (task.pausedReason === "worktrunk_operation_failed") return withPerPr({ outcome: "skipped", reason: "worktrunk-paused" });
|
||||
if (activeSessionRegistry.isPathActive(task.worktree)) return withPerPr({ outcome: "skipped", reason: "active-session" });
|
||||
if (!await isUsableTaskWorktree(this.options.rootDir, task.worktree)) return withPerPr({ outcome: "skipped", reason: "unusable-worktree" });
|
||||
|
||||
try {
|
||||
const inspection = await inspectBranchConflict({
|
||||
@@ -1552,17 +1568,17 @@ export class SelfHealingManager {
|
||||
|
||||
if (inspection.kind === "stale") {
|
||||
await auditor.database({ type: "task:pr-conflict-reclaim", target: task.id, metadata: { outcome: "skipped", reason: "stale" } });
|
||||
return { outcome: "skipped", reason: "stale" };
|
||||
return withPerPr({ outcome: "skipped", reason: "stale" });
|
||||
}
|
||||
if (inspection.kind === "stale-resolved") {
|
||||
await this.store.updateTask(task.id, { worktree: null, branch: null, baseCommitSha: null });
|
||||
await auditor.database({ type: "task:pr-conflict-reclaim", target: task.id, metadata: { outcome: "stale-resolved" } });
|
||||
return { outcome: "stale-resolved" };
|
||||
return withPerPr({ outcome: "stale-resolved" });
|
||||
}
|
||||
if (inspection.kind === "tip-already-merged") {
|
||||
await this.reclaimSelfOwnedBranchConflicts();
|
||||
await auditor.database({ type: "task:pr-conflict-reclaim", target: task.id, metadata: { outcome: "tip-already-merged" } });
|
||||
return { outcome: "tip-already-merged" };
|
||||
return withPerPr({ outcome: "tip-already-merged" });
|
||||
}
|
||||
if (inspection.kind === "live-foreign") {
|
||||
throw inspection.error;
|
||||
@@ -1586,7 +1602,7 @@ export class SelfHealingManager {
|
||||
await execAsync(`git branch -D ${JSON.stringify(task.branch)}`, { cwd: this.options.rootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024 });
|
||||
await this.store.updateTask(task.id, { worktree: null, branch: null, paused: false, pausedReason: undefined, status: null, error: null });
|
||||
await auditor.database({ type: "task:pr-conflict-reclaim", target: task.id, metadata: { outcome: "reclaimed", mode: "fully-subsumed", recoveredFromPaused: wasPausedBranchConflict } });
|
||||
return { outcome: "reclaimed" };
|
||||
return withPerPr({ outcome: "reclaimed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1607,7 +1623,7 @@ export class SelfHealingManager {
|
||||
});
|
||||
}
|
||||
await auditor.database({ type: "task:pr-conflict-reclaim", target: task.id, metadata: { outcome: "reclaimed", mode: inspection.kind } });
|
||||
return { outcome: "reclaimed" };
|
||||
return withPerPr({ outcome: "reclaimed" });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const patchPath = await preserveWorktreeChanges(this.options.rootDir, task.worktree, task.id);
|
||||
@@ -1644,7 +1660,7 @@ export class SelfHealingManager {
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
await this.store.logEntry(task.id, `Auto-recovery failed: branch conflict unrecoverable — ${message}`);
|
||||
}
|
||||
return { outcome: "paused-unrecoverable", reason: message };
|
||||
return withPerPr({ outcome: "paused-unrecoverable", reason: message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3875,7 +3891,7 @@ export class SelfHealingManager {
|
||||
mergeCommitMessage: landedCommit.subject,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
prNumber: getPrimaryPrInfo(task)?.number,
|
||||
};
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
@@ -4031,7 +4047,7 @@ export class SelfHealingManager {
|
||||
mergeCommitMessage: task.mergeDetails?.mergeCommitMessage ?? landed.subject,
|
||||
rebaseBaseSha: task.mergeDetails?.rebaseBaseSha ?? landed.rebaseBaseSha,
|
||||
mergedAt: task.mergeDetails?.mergedAt ?? new Date().toISOString(),
|
||||
prNumber: task.prInfo?.number,
|
||||
prNumber: getPrimaryPrInfo(task)?.number,
|
||||
},
|
||||
modifiedFiles: liveLandedFiles && liveLandedFiles.length > 0 ? liveLandedFiles : undefined,
|
||||
});
|
||||
@@ -4093,7 +4109,7 @@ export class SelfHealingManager {
|
||||
landedFiles: landedFiles ?? task.mergeDetails?.landedFiles,
|
||||
mergedAt: task.mergeDetails?.mergedAt ?? new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
prNumber: getPrimaryPrInfo(task)?.number,
|
||||
},
|
||||
modifiedFiles: landedFiles && landedFiles.length > 0 ? landedFiles : undefined,
|
||||
});
|
||||
@@ -4266,7 +4282,7 @@ export class SelfHealingManager {
|
||||
mergeCommitMessage: landedCommit.subject,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
prNumber: getPrimaryPrInfo(task)?.number,
|
||||
};
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
@@ -4559,7 +4575,7 @@ export class SelfHealingManager {
|
||||
commitSha: landed.sha,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
prNumber: getPrimaryPrInfo(task)?.number,
|
||||
};
|
||||
|
||||
const hardBlocker = getTaskHardMergeBlocker({
|
||||
@@ -4768,7 +4784,7 @@ export class SelfHealingManager {
|
||||
commitSha: check.landed.sha,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
prNumber: getPrimaryPrInfo(task)?.number,
|
||||
};
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
|
||||
Reference in New Issue
Block a user