feat(FN-4890): complete Step 3 — paused-scope decay auto-rebound

Fusion-Task-Id: FN-4890
Fusion-Task-Lineage: d1ae6585-7df0-481f-83c3-c7cdd45daf4f
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 09:53:21 -07:00
committed by gsxdsm
parent 295a5cff45
commit f71c2a1829
3 changed files with 146 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ export type ReliabilityFixture = {
recoverAlreadyMergedReviewTasks: () => Promise<number>;
recoverMisclassifiedFailures: () => Promise<number>;
clearStaleBlockedBy: () => Promise<number>;
autoReboundPausedScopeDecay: (opts?: { ignoreAgeGate?: boolean }) => Promise<number>;
reconcileDoneTaskIntegrity: () => Promise<number>;
};
};
@@ -103,6 +104,7 @@ export async function makeReliabilityFixture(input: {
recoverAlreadyMergedReviewTasks: async () => manager.recoverAlreadyMergedReviewTasks(),
recoverMisclassifiedFailures: async () => manager.recoverMisclassifiedFailures(),
clearStaleBlockedBy: async () => manager.clearStaleBlockedBy(),
autoReboundPausedScopeDecay: async (opts) => manager.autoReboundPausedScopeDecay(opts),
reconcileDoneTaskIntegrity: async () => manager.reconcileDoneTaskIntegrity(),
},
};

View File

@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
type AuditEvent = { mutationType: string; taskId?: string; metadata?: Record<string, unknown> };
function makeTask(id: string, overrides: Partial<Task> = {}): Task {
return {
id,
title: id,
description: id,
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
log: [],
...overrides,
} as Task;
}
function makeStore(tasks: Task[], settings: Partial<Settings> = {}) {
const byId = new Map(tasks.map((t) => [t.id, t]));
const audits: AuditEvent[] = [];
const emitter = new EventEmitter();
const store = Object.assign(emitter, {
getSettings: vi.fn(async () => ({
globalPause: false,
enginePaused: false,
pausedScopeDecayMs: 30 * 60_000,
...settings,
})),
listTasks: vi.fn(async ({ column, includeArchived }: any = {}) =>
[...byId.values()].filter((task) => {
if (column && task.column !== column) return false;
if (includeArchived === false && task.column === "archived") return false;
return true;
}),
),
moveTask: vi.fn(async (id: string, column: Task["column"], _opts?: any) => {
byId.set(id, { ...byId.get(id)!, column, paused: false, pausedReason: undefined, blockedBy: null, overlapBlockedBy: null } as Task);
return byId.get(id)!;
}),
updateTask: vi.fn(async (id: string, updates: Partial<Task>) => {
byId.set(id, { ...byId.get(id)!, ...updates } as Task);
return byId.get(id)!;
}),
getTask: vi.fn(async (id: string) => byId.get(id)),
logEntry: vi.fn(async () => undefined),
recordRunAuditEvent: vi.fn(async (event: any) => {
audits.push({ mutationType: event.mutationType, taskId: event.taskId, metadata: event.metadata });
}),
});
return { store: store as unknown as TaskStore & EventEmitter, byId, audits };
}
describe("reliability interactions: paused scope decay", () => {
it("rebounds stale paused in-progress holder with followers and emits audit", async () => {
const now = Date.now();
const holder = makeTask("FN-1", {
column: "in-progress",
paused: true,
pausedReason: "waiting",
columnMovedAt: new Date(now - 31 * 60_000).toISOString(),
currentStep: 2,
steps: [{ id: "s1", title: "x", status: "done" } as any],
worktree: "/tmp/wt",
});
const follower = makeTask("FN-2", { column: "todo", blockedBy: "FN-1", status: "queued" });
const { store, byId, audits } = makeStore([holder, follower]);
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getExecutingTaskIds: () => new Set() });
const count = await manager.autoReboundPausedScopeDecay();
expect(count).toBe(1);
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "todo", expect.objectContaining({
preserveProgress: true,
preserveWorktree: true,
preserveResumeState: true,
moveSource: "engine",
}));
expect((store.moveTask as any).mock.calls[0][2].moveSource).toBe("engine");
expect((store.moveTask as any).mock.calls[0][2].moveSource).not.toBe("user");
expect(byId.get("FN-1")?.currentStep).toBe(2);
expect(byId.get("FN-1")?.worktree).toBe("/tmp/wt");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", expect.stringContaining("Auto-rebounded (FN-4890)"));
expect(audits.some((event) => event.mutationType === "task:auto-rebound-paused-scope-decay")).toBe(true);
expect(byId.get("FN-1")?.column).toBe("todo");
expect(byId.get("FN-2")?.blockedBy).toBe("FN-1");
});
it("supports ignoreAgeGate override", async () => {
const now = Date.now();
const holder = makeTask("FN-3", {
column: "in-progress",
paused: true,
columnMovedAt: new Date(now - 1_000).toISOString(),
});
const follower = makeTask("FN-4", { column: "todo", blockedBy: "FN-3" });
const { store } = makeStore([holder, follower], { pausedScopeDecayMs: 60_000 });
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getExecutingTaskIds: () => new Set() });
expect(await manager.autoReboundPausedScopeDecay()).toBe(0);
expect(await manager.autoReboundPausedScopeDecay({ ignoreAgeGate: true })).toBe(1);
});
it("no-op when there are no followers", async () => {
const now = Date.now();
const holder = makeTask("FN-5", { column: "in-progress", paused: true, columnMovedAt: new Date(now - 31 * 60_000).toISOString() });
const unrelated = makeTask("FN-6", { column: "todo", blockedBy: "FN-X" });
const { store } = makeStore([holder, unrelated]);
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getExecutingTaskIds: () => new Set() });
expect(await manager.autoReboundPausedScopeDecay()).toBe(0);
});
it.each([
{ name: "threshold disabled", holder: { paused: true }, settings: { pausedScopeDecayMs: 0 } },
{ name: "excluded paused reason", holder: { paused: true, pausedReason: "branch-conflict-unrecoverable" as const } },
{ name: "not paused", holder: { paused: false } },
{ name: "age below threshold", holder: { paused: true }, settings: { pausedScopeDecayMs: 60_000 }, ageMs: 500 },
])("no-op: $name", async ({ holder, settings, ageMs }) => {
const now = Date.now();
const effectiveAgeMs = ageMs ?? 31 * 60_000;
const pausedHolder = makeTask("FN-8", {
column: "in-progress",
columnMovedAt: new Date(now - effectiveAgeMs).toISOString(),
...holder,
});
const follower = makeTask("FN-9", { column: "todo", blockedBy: "FN-8" });
const { store } = makeStore([pausedHolder, follower], settings);
const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getExecutingTaskIds: () => new Set() });
expect(await manager.autoReboundPausedScopeDecay()).toBe(0);
});
});

View File

@@ -480,6 +480,12 @@ export class SelfHealingManager {
private orphanArchivedAcknowledged = new Set<string>();
private finalizeUnprovenWarned = new Set<string>();
private static readonly PAUSED_SCOPE_DECAY_EXCLUDED_REASONS = new Set([
"branch-conflict-unrecoverable",
"worktrunk_operation_failed",
"token_budget_exceeded",
]);
constructor(
private store: TaskStore,
private options: SelfHealingOptions,
@@ -1135,6 +1141,7 @@ export class SelfHealingManager {
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() },
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks() },
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
{ name: "auto-rebound-paused-scope-decay", fn: () => this.autoReboundPausedScopeDecay() },
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies() },
{ name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts() },
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },