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:
committed by
gsxdsm
parent
295a5cff45
commit
f71c2a1829
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user