feat(FN-3856): add stuck merge deadlock recovery sweep and self-healing aut
Merges deadlock recovery for stuck merge tasks (FN-3856) — the engine now runs an already-merged sweep before pausing blocked tasks, and recovers metadata alignment with MergeDetails type. Also adds memory backup CLI commands with core support, a resizable split pane to the mailbox view, peer-agent Fusion-Task-Id: FN-3856
This commit is contained in:
5
.changeset/FN-3856-deadlock-recovery.md
Normal file
5
.changeset/FN-3856-deadlock-recovery.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Auto-recover stuck merge deadlocks where task content is already on main.
|
||||
@@ -434,6 +434,7 @@ describe("SelfHealingManager", () => {
|
||||
} as unknown as Settings);
|
||||
const recoverNoProgressNoTaskDoneFailures = vi.spyOn(manager, "recoverNoProgressNoTaskDoneFailures").mockResolvedValue(1);
|
||||
const recoverCompletedTasks = vi.spyOn(manager, "recoverCompletedTasks").mockResolvedValue(1);
|
||||
const recoverStuckMergeDeadlocks = vi.spyOn(manager, "recoverStuckMergeDeadlocks").mockResolvedValue(1);
|
||||
const recoverMisclassifiedFailures = vi.spyOn(manager, "recoverMisclassifiedFailures").mockResolvedValue(1);
|
||||
const recoverPartialProgressNoTaskDoneFailures = vi.spyOn(manager, "recoverPartialProgressNoTaskDoneFailures").mockResolvedValue(1);
|
||||
const recoverOrphanedExecutions = vi.spyOn(manager, "recoverOrphanedExecutions").mockResolvedValue(1);
|
||||
@@ -444,6 +445,7 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
expect(recoverNoProgressNoTaskDoneFailures).toHaveBeenCalledTimes(1);
|
||||
expect(recoverCompletedTasks).toHaveBeenCalledTimes(1);
|
||||
expect(recoverStuckMergeDeadlocks).toHaveBeenCalledTimes(1);
|
||||
expect(recoverMisclassifiedFailures).toHaveBeenCalledTimes(1);
|
||||
expect(recoverPartialProgressNoTaskDoneFailures).toHaveBeenCalledTimes(1);
|
||||
expect(recoverOrphanedExecutions).toHaveBeenCalledTimes(1);
|
||||
@@ -2365,6 +2367,188 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverStuckMergeDeadlocks", () => {
|
||||
const baseSettings = { globalPause: false, enginePaused: false, defaultBaseBranch: "main" } as unknown as Settings;
|
||||
|
||||
it("recovers phantom-merged deadlocks, moves task to done, and clears blocked dependents", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "FN-stuck", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt", branch: "fusion/fn-stuck", baseBranch: "main", prInfo: { number: 77 }, log: [] },
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: "FN-dep", column: "todo", blockedBy: "FN-stuck", log: [] }])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockedExecSync.mockImplementation((command: string | Buffer) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("Fusion-Task-Id: FN-stuck")) return "abc12345\x1fRecovered subject\n" as any;
|
||||
if (cmd.includes("--shortstat")) return " 2 files changed, 3 insertions(+), 1 deletions(-)\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const result = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-stuck", "done");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", expect.objectContaining({
|
||||
status: null,
|
||||
error: null,
|
||||
mergeRetries: 0,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
mergeDetails: expect.objectContaining({ commitSha: "abc12345", mergeConfirmed: true }),
|
||||
}));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-dep", { blockedBy: null });
|
||||
expect(
|
||||
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("git worktree remove '/tmp/wt' --force")),
|
||||
).toBe(true);
|
||||
expect(getSelfHealingLogger().log).toHaveBeenCalledWith(expect.stringContaining("self-heal:deadlock-recovered"));
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("pauses genuine failures and leaves blockedBy untouched", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([{ id: "FN-stuck", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt", log: [] }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: "FN-dep", column: "todo", blockedBy: "FN-stuck", log: [] }])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockedExecSync.mockReturnValue("" as any);
|
||||
|
||||
const result = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", { paused: true });
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-dep", { blockedBy: null });
|
||||
expect(getSelfHealingLogger().warn).toHaveBeenCalledWith(expect.stringContaining("paused-for-manual"));
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("is idempotent and cooldown-gated", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([{ id: "FN-stuck", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt", log: [] }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: "FN-stuck", column: "done", paused: false, status: null, mergeRetries: 0, mergeDetails: { mergeConfirmed: true }, log: [] }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockedExecSync.mockImplementation((command: string | Buffer) => String(command).includes("Fusion-Task-Id: FN-stuck") ? ("abc12345\x1fRecovered subject\n" as any) : ("" as any));
|
||||
|
||||
const first = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
const second = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
|
||||
expect(first).toBe(1);
|
||||
expect(second).toBe(0);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("enforces cooldown for repeated genuine-failure sweeps", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([{ id: "FN-cool", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt", log: [] }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: "FN-cool", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt", log: [] }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockedExecSync.mockReturnValue("" as any);
|
||||
|
||||
const first = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
const updateCallsAfterFirst = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
const second = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
|
||||
expect(first).toBe(1);
|
||||
expect(second).toBe(0);
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(updateCallsAfterFirst);
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("short-circuits when globalPause or enginePaused is active", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
mockedExecSync.mockClear();
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: true, enginePaused: false });
|
||||
expect(await managerWithRecovery.recoverStuckMergeDeadlocks()).toBe(0);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: true });
|
||||
expect(await managerWithRecovery.recoverStuckMergeDeadlocks()).toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
expect(mockedExecSync).not.toHaveBeenCalled();
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("isolates per-task errors and continues with other stuck tasks", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "FN-err", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt1", log: [] },
|
||||
{ id: "FN-ok", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt2", log: [] },
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockedExecSync.mockImplementation((command: string | Buffer) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("Fusion-Task-Id: FN-ok")) return "def67890\x1fok\n" as any;
|
||||
if (cmd.includes("Fusion-Task-Id: FN-err")) return "abcabc12\x1ferr\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string) => {
|
||||
if (id === "FN-err") throw new Error("update failed");
|
||||
return {} as Task;
|
||||
});
|
||||
|
||||
const result = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(getSelfHealingLogger().warn).toHaveBeenCalledWith(expect.stringContaining("self-heal:deadlock-recovery-error"));
|
||||
expect((managerWithRecovery as any).deadlockRecoveryCooldown.get("FN-err")).toBeTypeOf("number");
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("recovers worktree-only orphans and reproduces three-task incident", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "FN-3794", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt-a", log: [] },
|
||||
{ id: "FN-3814", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt-b", log: [] },
|
||||
{ id: "FN-3829", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt-c", log: [] },
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: "FN-3842", column: "todo", blockedBy: "FN-3794", log: [] }])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockedExecSync.mockImplementation((command: string | Buffer) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("Fusion-Task-Id: FN-3794")) return "278a2825\x1fone\n" as any;
|
||||
if (cmd.includes("Fusion-Task-Id: FN-3814")) return "69c25e2b\x1ftwo\n" as any;
|
||||
if (cmd.includes("Fusion-Task-Id: FN-3829")) return "0d3f51b6\x1fthree\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const result = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
|
||||
expect(result).toBe(3);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3842", { blockedBy: null });
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverAlreadyMergedReviewTasks", () => {
|
||||
it("short-circuits when globalPause or enginePaused is active", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
@@ -3816,6 +4000,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
(vi.spyOn(manager as any, "recoverStaleMergingStatus").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverMergeableReviewTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverMergedReviewTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverStuckMergeDeadlocks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverMisclassifiedFailures").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverNoProgressNoTaskDoneFailures").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverPartialProgressNoTaskDoneFailures").mockResolvedValue(0) as any);
|
||||
@@ -3841,6 +4026,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
(vi.spyOn(manager as any, "recoverStaleMergingStatus").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverMergeableReviewTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverMergedReviewTasks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverStuckMergeDeadlocks").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverMisclassifiedFailures").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverNoProgressNoTaskDoneFailures").mockResolvedValue(0) as any);
|
||||
(vi.spyOn(manager as any, "recoverPartialProgressNoTaskDoneFailures").mockResolvedValue(0) as any);
|
||||
@@ -3910,6 +4096,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
makeSlow("recoverStaleMergingStatus");
|
||||
makeSlow("recoverMergeableReviewTasks");
|
||||
makeSlow("recoverMergedReviewTasks");
|
||||
makeSlow("recoverStuckMergeDeadlocks");
|
||||
makeSlow("recoverMisclassifiedFailures");
|
||||
makeSlow("recoverNoProgressNoTaskDoneFailures");
|
||||
makeSlow("recoverPartialProgressNoTaskDoneFailures");
|
||||
@@ -3937,6 +4124,7 @@ describe("maintenance cycle concurrency", () => {
|
||||
"recoverStaleMergingStatus",
|
||||
"recoverMergeableReviewTasks",
|
||||
"recoverMergedReviewTasks",
|
||||
"recoverStuckMergeDeadlocks",
|
||||
"recoverMisclassifiedFailures",
|
||||
"recoverNoProgressNoTaskDoneFailures",
|
||||
"recoverPartialProgressNoTaskDoneFailures",
|
||||
|
||||
@@ -121,6 +121,7 @@ const ORPHANED_WITH_WORKTREE_GRACE_MS = 300_000;
|
||||
*/
|
||||
const MAX_TASK_DONE_RETRIES = 3;
|
||||
const MAX_AUTO_MERGE_RETRIES = 3;
|
||||
const DEADLOCK_RECOVERY_COOLDOWN_MS = 15 * 60_000;
|
||||
const DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS = 5 * 60_000;
|
||||
|
||||
interface LandedTaskCommit {
|
||||
@@ -181,6 +182,9 @@ export class SelfHealingManager {
|
||||
// ── Event listener cleanup ──────────────────────────────────────────
|
||||
private settingsListener: ((data: { settings: Settings; previous: Settings }) => void) | null = null;
|
||||
|
||||
// ── Per-task deadlock recovery cooldown ─────────────────────────────
|
||||
private deadlockRecoveryCooldown: Map<string, number> = new Map();
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private options: SelfHealingOptions,
|
||||
@@ -227,6 +231,8 @@ export class SelfHealingManager {
|
||||
{ name: "failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps().then(() => undefined) },
|
||||
{ name: "interrupted-merging", fn: () => this.recoverInterruptedMergingTasks().then(() => undefined) },
|
||||
{ name: "done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata().then(() => undefined) },
|
||||
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks().then(() => undefined) },
|
||||
{ name: "recover-stuck-merge-deadlocks", fn: () => this.recoverStuckMergeDeadlocks().then(() => undefined) },
|
||||
{ name: "misclassified-failures", fn: () => this.recoverMisclassifiedFailures().then(() => undefined) },
|
||||
{ name: "partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures().then(() => undefined) },
|
||||
{ name: "orphaned-executions", fn: () => this.recoverOrphanedExecutions().then(() => undefined) },
|
||||
@@ -833,6 +839,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
|
||||
{ name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
|
||||
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() },
|
||||
{ name: "recover-stuck-merge-deadlocks", fn: () => this.recoverStuckMergeDeadlocks() },
|
||||
{ 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() },
|
||||
@@ -1677,6 +1684,115 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover deadlocked retry-exhausted merge failures that are still blocking
|
||||
* dispatch via `blockedBy` or retained worktree ownership.
|
||||
*/
|
||||
async recoverStuckMergeDeadlocks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
const inReview = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const triage = await this.store.listTasks({ column: "triage", slim: true });
|
||||
const todo = await this.store.listTasks({ column: "todo", slim: true });
|
||||
const inProgress = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||
|
||||
const dependentsByBlocker = new Map<string, Task[]>();
|
||||
for (const task of [...triage, ...todo, ...inProgress]) {
|
||||
if (!task.blockedBy) continue;
|
||||
const dependents = dependentsByBlocker.get(task.blockedBy) ?? [];
|
||||
dependents.push(task);
|
||||
dependentsByBlocker.set(task.blockedBy, dependents);
|
||||
}
|
||||
|
||||
const candidates = inReview.filter((task) => {
|
||||
const cooldownStart = this.deadlockRecoveryCooldown.get(task.id) ?? 0;
|
||||
const cooldownElapsed = now - cooldownStart;
|
||||
const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some(
|
||||
(dep) => dep.column === "triage" || dep.column === "todo",
|
||||
);
|
||||
return task.column === "in-review" &&
|
||||
!task.paused &&
|
||||
task.status === "failed" &&
|
||||
(task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES &&
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
(hasBlockedDependents || Boolean(task.worktree)) &&
|
||||
cooldownElapsed >= DEADLOCK_RECOVERY_COOLDOWN_MS;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
|
||||
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
|
||||
try {
|
||||
const landedCommit = await this.findLandedTaskCommit(task);
|
||||
if (landedCommit) {
|
||||
const mergeDetails: MergeDetails = {
|
||||
commitSha: landedCommit.sha,
|
||||
filesChanged: landedCommit.filesChanged,
|
||||
insertions: landedCommit.insertions,
|
||||
deletions: landedCommit.deletions,
|
||||
mergeCommitMessage: landedCommit.subject,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
};
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
status: null,
|
||||
error: null,
|
||||
mergeRetries: 0,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
mergeDetails,
|
||||
});
|
||||
await this.store.moveTask(task.id, "done");
|
||||
await this.cleanupInterruptedMergeArtifacts(task);
|
||||
|
||||
const clearedDependents: string[] = [];
|
||||
for (const dep of blockedDependents) {
|
||||
try {
|
||||
await this.store.updateTask(dep.id, { blockedBy: null });
|
||||
await this.store.logEntry(dep.id, `Auto-recovered: cleared stale blockedBy ${task.id} after deadlock recovery`);
|
||||
clearedDependents.push(dep.id);
|
||||
} catch (depErr: unknown) {
|
||||
const depErrMessage = depErr instanceof Error ? depErr.message : String(depErr);
|
||||
log.warn(`self-heal:deadlock-recovery-dependent-error ${JSON.stringify({ blockerTaskId: task.id, dependentTaskId: dep.id, error: depErrMessage })}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-recovered: merge deadlock resolved via landed commit ${landedCommit.sha.slice(0, 8)}${clearedDependents.length > 0 ? `; cleared blockedBy on ${clearedDependents.join(", ")}` : ""}`,
|
||||
);
|
||||
log.log(`self-heal:deadlock-recovered ${JSON.stringify({ stuckTaskId: task.id, blockedTaskIds, attributedSha: landedCommit.sha, action: "reattributed" })}`);
|
||||
recovered++;
|
||||
} else {
|
||||
await this.store.updateTask(task.id, { paused: true });
|
||||
await this.store.logEntry(task.id, "merge-deadlock-detected: requires manual intervention — verified content not on main");
|
||||
log.warn(`self-heal:deadlock-recovered ${JSON.stringify({ stuckTaskId: task.id, blockedTaskIds, attributedSha: null, action: "paused-for-manual" })}`);
|
||||
recovered++;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`self-heal:deadlock-recovery-error ${JSON.stringify({ stuckTaskId: task.id, blockedTaskIds, error: errorMessage })}`);
|
||||
} finally {
|
||||
this.deadlockRecoveryCooldown.set(task.id, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
return recovered;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Stuck merge deadlock recovery failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover retry-exhausted failed review tasks whose content already landed on
|
||||
* the integration branch via a non-canonical merge lineage.
|
||||
|
||||
Reference in New Issue
Block a user