feat(FN-4485): extend reclaim sweep to paused review conflicts

Fusion-Task-Id: FN-4485
Fusion-Task-Lineage: 034088dc-ebc4-4e12-8314-39419d41b23f
This commit is contained in:
Fusion
2026-05-14 10:49:37 -07:00
committed by gsxdsm
parent 353f89d1b8
commit c67aa91929
4 changed files with 145 additions and 15 deletions

View File

@@ -0,0 +1,110 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import * as branchConflicts from "../branch-conflicts.js";
import * as worktreePool from "../worktree-pool.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getSettings = vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false });
(emitter as any).listTasks = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;
}
describe("self-healing reclaim paused review", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/test" });
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
});
it("reclaims paused in-review branch conflict, clears paused state, and requeues to todo with audit metadata", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ id: "FN-4485", column: "in-review", checkedOutBy: null, branch: "fusion/fn-4485", worktree: "/tmp/fn-4485", paused: true, pausedReason: "branch-conflict-unrecoverable", status: "failed", lineageId: "lin-1" },
]);
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: "/tmp/fn-4485",
tipSha: "abc123def456",
taskAttributedCommitCount: 0,
strandedCommits: [],
} as any);
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-4485", expect.objectContaining({ paused: false, pausedReason: undefined, status: null, error: null }));
expect(store.moveTask).toHaveBeenCalledWith("FN-4485", "todo", expect.objectContaining({ moveSource: "engine" }));
expect(store.logEntry).toHaveBeenCalledWith("FN-4485", expect.stringContaining("[recovery] reclaim-paused-review"));
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "branch:auto-reclaim",
metadata: expect.objectContaining({ recoveredFromPaused: true, previousPausedReason: "branch-conflict-unrecoverable" }),
}));
});
it("reclaims paused in-progress branch-conflict task without moving columns", async () => {
const activeStore = { listActiveHeartbeatRuns: vi.fn().mockResolvedValue([{ startedAt: new Date().toISOString(), contextSnapshot: { taskId: "FN-9998" } }]) } as any;
manager = new SelfHealingManager(store, { rootDir: "/tmp/test", agentStore: activeStore });
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ id: "FN-4486", column: "in-progress", checkedOutBy: null, branch: "fusion/fn-4486", worktree: "/tmp/fn-4486", paused: true, pausedReason: "branch-conflict-unrecoverable", status: "failed" },
{ id: "FN-9998", column: "in-progress", checkedOutBy: null, branch: "fusion/fn-9998", worktree: "/tmp/fn-9998", paused: true, pausedReason: "branch-conflict-unrecoverable", status: "failed" },
])
.mockResolvedValueOnce([]);
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({ kind: "fully-subsumed", livePath: "/tmp/fn-4486", tipSha: "abc123def456" } as any);
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(1);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-4486", "todo", expect.anything());
expect((store.updateTask as any).mock.calls.some((call: any[]) => call[0] === "FN-9998")).toBe(false);
});
it("leaves foreign conflicts parked", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ id: "FN-4487", column: "in-review", checkedOutBy: null, branch: "fusion/fn-4487", worktree: "/tmp/fn-4487", paused: true, pausedReason: "branch-conflict-unrecoverable", status: "failed" },
]);
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
kind: "live-foreign",
livePath: "/tmp/foreign",
error: new Error("foreign owner"),
} as any);
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(store.moveTask).toHaveBeenCalledWith("FN-4487", "in-review");
});
it("does not reclaim userPaused tasks without branch-conflict paused reason", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([
{ id: "FN-4488", column: "todo", checkedOutBy: null, branch: "fusion/fn-4488", worktree: "/tmp/fn-4488", userPaused: true, paused: true, pausedReason: undefined },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const inspectSpy = vi.spyOn(branchConflicts, "inspectBranchConflict");
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(inspectSpy).not.toHaveBeenCalled();
});
});

View File

@@ -5840,7 +5840,7 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-509", { worktree: "/tmp/fn-509", branch: "fusion/fn-509" });
expect(store.updateTask).toHaveBeenCalledWith("FN-509", expect.objectContaining({ worktree: "/tmp/fn-509", branch: "fusion/fn-509", status: null, paused: false }));
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
domain: "git",
@@ -5865,7 +5865,7 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-500", { worktree: "/tmp/fn-500", branch: "fusion/fn-500" });
expect(store.updateTask).toHaveBeenCalledWith("FN-500", expect.objectContaining({ worktree: "/tmp/fn-500", branch: "fusion/fn-500", status: null, paused: false }));
});
it("skips checked out tasks", async () => {
@@ -5905,8 +5905,9 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
expect(inspectSpy).not.toHaveBeenCalled();
});
it("only scans todo and in-progress columns", async () => {
it("scans todo, in-progress, and paused in-review columns", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
@@ -5914,6 +5915,7 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
expect(store.listTasks).toHaveBeenNthCalledWith(1, { column: "todo", slim: true });
expect(store.listTasks).toHaveBeenNthCalledWith(2, { column: "in-progress", slim: true });
expect(store.listTasks).toHaveBeenNthCalledWith(3, { column: "in-review", slim: true });
});
it("escalates live-foreign conflicts to in-review failed", async () => {

View File

@@ -1399,10 +1399,11 @@ export class SelfHealingManager {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const candidates = [
...(await this.store.listTasks({ column: "todo", slim: true })),
...(await this.store.listTasks({ column: "in-progress", slim: true })),
];
const todoCandidates = await this.store.listTasks({ column: "todo", slim: true });
const inProgressCandidates = await this.store.listTasks({ column: "in-progress", slim: true });
const inReviewPausedCandidates = (await this.store.listTasks({ column: "in-review", slim: true }))
.filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable");
const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates];
const activeTaskIds = new Set<string>();
if (this.options.agentStore) {
@@ -1427,6 +1428,7 @@ export class SelfHealingManager {
let recovered = 0;
for (const task of candidates) {
if (task.checkedOutBy || activeTaskIds.has(task.id.toUpperCase()) || !task.branch || !task.worktree) continue;
if (task.userPaused && task.pausedReason !== "branch-conflict-unrecoverable") continue;
if (!await isUsableTaskWorktree(this.options.rootDir, task.worktree)) continue;
try {
@@ -1449,23 +1451,37 @@ export class SelfHealingManager {
const preservedCommitCount = inspection.kind === "fully-subsumed"
? 0
: inspection.taskAttributedCommitCount;
if (inspection.kind !== "fully-subsumed" && preservedCommitCount <= 0) {
continue;
}
await this.store.updateTask(task.id, { worktree: inspection.livePath, branch: task.branch });
const wasPausedBranchConflict = task.paused === true && task.pausedReason === "branch-conflict-unrecoverable";
await this.store.updateTask(task.id, {
worktree: inspection.livePath,
branch: task.branch,
paused: false,
pausedReason: undefined,
status: null,
error: null,
});
await this.store.logEntry(
task.id,
`[recovery] reclaimed existing worktree for ${task.id} at ${inspection.livePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
`[recovery] ${wasPausedBranchConflict ? "reclaim-paused-review" : "reclaim-self-owned"} ${task.id} at ${inspection.livePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
);
if (task.column === "in-review") {
await this.store.moveTask(task.id, "todo", {
moveSource: "engine",
preserveWorktree: true,
preserveProgress: true,
preserveResumeState: true,
});
}
try {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "reclaim-self-owned-branch-conflicts",
phase: wasPausedBranchConflict ? "reclaim-paused-review" : "reclaim-self-owned-branch-conflicts",
});
await auditor.git({
type: "branch:auto-reclaim",
@@ -1477,6 +1493,8 @@ export class SelfHealingManager {
existingTipSha: inspection.tipSha,
strandedCommitCount: inspection.kind === "fully-subsumed" ? 0 : inspection.strandedCommits.length,
subsumed: inspection.kind === "fully-subsumed",
recoveredFromPaused: wasPausedBranchConflict,
previousPausedReason: wasPausedBranchConflict ? task.pausedReason : null,
trigger: "self-healing-sweep",
},
});