feat(FN-5034): complete Step 5 — add stale paused todo self-healing surfacing
Fusion-Task-Id: FN-5034 Fusion-Task-Lineage: 1e315656-30d8-41d2-9802-0d9e155f0ba8
This commit is contained in:
committed by
gsxdsm
parent
d192154b2b
commit
353845a6b8
@@ -523,6 +523,7 @@ describe("SelfHealingManager", () => {
|
||||
const clearStaleBlockedBy = vi.spyOn(manager, "clearStaleBlockedBy").mockResolvedValue(1);
|
||||
const surfaceInReviewStalls = vi.spyOn(manager, "surfaceInReviewStalls").mockResolvedValue(1);
|
||||
const surfaceStalePausedReviews = vi.spyOn(manager, "surfaceStalePausedReviews").mockResolvedValue(1);
|
||||
const surfaceStalePausedTodos = vi.spyOn(manager, "surfaceStalePausedTodos").mockResolvedValue(1);
|
||||
|
||||
await manager.runStartupRecovery();
|
||||
|
||||
@@ -538,6 +539,7 @@ describe("SelfHealingManager", () => {
|
||||
expect(clearStaleBlockedBy).toHaveBeenCalledTimes(1);
|
||||
expect(surfaceInReviewStalls).toHaveBeenCalledTimes(1);
|
||||
expect(surfaceStalePausedReviews).toHaveBeenCalledTimes(1);
|
||||
expect(surfaceStalePausedTodos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runStartupRecovery clears stale blockedBy rows", async () => {
|
||||
@@ -4960,6 +4962,96 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("surfaceStalePausedTodos", () => {
|
||||
function pausedTodoTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "FN-5034",
|
||||
column: "todo",
|
||||
paused: true,
|
||||
pausedReason: "manual-hold",
|
||||
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
log: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("logs for stale paused todo tasks", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z"));
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ stalePausedTodoThresholdMs: 24 * 60 * 60_000 });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([pausedTodoTask()]);
|
||||
|
||||
expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(1);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-5034",
|
||||
expect.stringContaining("Stale paused todo surfaced [stale-paused-todo]: paused"),
|
||||
);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-5034",
|
||||
expect.stringContaining("disposition options — unpause, move to triage, archive, or create follow-up task"),
|
||||
);
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips under threshold and for unpaused/non-todo tasks", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ stalePausedTodoThresholdMs: 24 * 60 * 60_000 });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
pausedTodoTask(),
|
||||
pausedTodoTask({ id: "FN-UP", paused: false }),
|
||||
pausedTodoTask({ id: "FN-IR", column: "in-review" }),
|
||||
]);
|
||||
|
||||
expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0);
|
||||
expect(store.logEntry).not.toHaveBeenCalled();
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("returns zero while paused or when threshold is disabled", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z"));
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ stalePausedTodoThresholdMs: 24 * 60 * 60_000, globalPause: true })
|
||||
.mockResolvedValueOnce({ stalePausedTodoThresholdMs: 24 * 60 * 60_000, enginePaused: true })
|
||||
.mockResolvedValueOnce({ stalePausedTodoThresholdMs: 0 });
|
||||
|
||||
expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0);
|
||||
expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0);
|
||||
expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0);
|
||||
expect(store.logEntry).not.toHaveBeenCalled();
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("dedupes within threshold window and re-emits after window", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z"));
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ stalePausedTodoThresholdMs: 24 * 60 * 60_000 });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([
|
||||
pausedTodoTask({
|
||||
log: [{
|
||||
timestamp: "2026-01-01T12:00:00.000Z",
|
||||
action: "Stale paused todo surfaced [stale-paused-todo]: recent",
|
||||
}],
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
pausedTodoTask({
|
||||
log: [{
|
||||
timestamp: "2025-12-30T00:00:00.000Z",
|
||||
action: "Stale paused todo surfaced [stale-paused-todo]: old",
|
||||
}],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0);
|
||||
expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(1);
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverGhostReviewTasks", () => {
|
||||
it("preserves failed in-review tasks so actionable merge failures are not ghost-retried", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
|
||||
@@ -662,6 +662,7 @@ export class SelfHealingManager {
|
||||
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches().then(() => undefined) },
|
||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
|
||||
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews().then(() => undefined) },
|
||||
{ name: "surface-stale-paused-todos", fn: () => this.surfaceStalePausedTodos().then(() => undefined) },
|
||||
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates().then(() => undefined) },
|
||||
];
|
||||
|
||||
@@ -1248,6 +1249,7 @@ export class SelfHealingManager {
|
||||
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches() },
|
||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
|
||||
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews() },
|
||||
{ name: "surface-stale-paused-todos", fn: () => this.surfaceStalePausedTodos() },
|
||||
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates() },
|
||||
];
|
||||
for (const fn of batch2Fns) {
|
||||
@@ -4085,6 +4087,52 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
async surfaceStalePausedTodos(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const cycleStartMs = Date.now();
|
||||
const thresholdMs = settings.stalePausedTodoThresholdMs;
|
||||
if (!thresholdMs || thresholdMs <= 0) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "todo", slim: false });
|
||||
let surfaced = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.paused !== true) continue;
|
||||
const signal = getStalePausedTodoSignal(task, { now: cycleStartMs, thresholdMs });
|
||||
if (!signal) continue;
|
||||
if (Date.parse(task.updatedAt) >= cycleStartMs) continue;
|
||||
|
||||
const previous = [...(task.log ?? [])]
|
||||
.reverse()
|
||||
.find((entry) => entry.action.startsWith("Stale paused todo surfaced ["));
|
||||
if (previous) {
|
||||
const parsed = /^Stale paused todo surfaced \[([^\]]+)\]/.exec(previous.action);
|
||||
const previousCode = parsed?.[1];
|
||||
const previousAt = Date.parse(previous.timestamp);
|
||||
if (Number.isFinite(previousAt) && previousAt >= cycleStartMs - thresholdMs && previousCode === signal.code) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const hours = (signal.ageMs / 3_600_000).toFixed(1);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Stale paused todo surfaced [${signal.code}]: paused ${hours}h beyond ${(thresholdMs / 3_600_000).toFixed(1)}h threshold; disposition options — unpause, move to triage, archive, or create follow-up task. pausedReason=${signal.pausedReason ?? "none"}`,
|
||||
);
|
||||
surfaced += 1;
|
||||
}
|
||||
|
||||
return surfaced;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Stale paused todo surfacing failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async recoverGhostReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
|
||||
Reference in New Issue
Block a user