Prevent transient automatic-retry tasks from appearing as terminal failures. - Centralize pending-recovery and manual-retry presentation rules. - Suppress failed styling, failure alerts, and Retry actions across list, card, and detail views. - Cover recovery timing and desktop/mobile task surfaces with regression tests. Files changed: .changeset/fn-8167-transient-retry-affordance.md | 7 ++ packages/dashboard/app/components/ListView.tsx | 16 ++--- packages/dashboard/app/components/TaskCard.tsx | 13 ++-- .../dashboard/app/components/TaskDetailModal.tsx | 13 ++-- .../app/components/__tests__/ListView.test.tsx | 38 +++++++++++ .../app/components/__tests__/TaskCard.test.tsx | 22 +++++++ .../__tests__/TaskDetailModal.rendering.test.tsx | 26 ++++++++ .../app/utils/__tests__/taskRecovery.test.ts | 77 ++++++++++++++++++++++ packages/dashboard/app/utils/taskRecovery.ts | 32 +++++++++ 9 files changed, 215 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-8167 Fusion-Task-Lineage: 60d599e2-2e0e-46e7-ad16-cc6a836b5ac7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
78 lines
3.1 KiB
TypeScript
78 lines
3.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { Task } from "@fusion/core";
|
|
import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../taskRecovery";
|
|
|
|
const nowMs = Date.parse("2026-07-16T12:00:00.000Z");
|
|
|
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
|
return {
|
|
id: "FN-8167",
|
|
title: "Recovery fixture",
|
|
description: "",
|
|
column: "todo",
|
|
steps: [],
|
|
dependencies: [],
|
|
status: undefined,
|
|
...overrides,
|
|
} as Task;
|
|
}
|
|
|
|
describe("task recovery presentation", () => {
|
|
it("recognizes only finite strictly-future recovery schedules as pending", () => {
|
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: new Date(nowMs + 1).toISOString() }), nowMs)).toBe(true);
|
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: new Date(nowMs).toISOString() }), nowMs)).toBe(false);
|
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: new Date(nowMs - 1).toISOString() }), nowMs)).toBe(false);
|
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: "not-a-date" }), nowMs)).toBe(false);
|
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: null }), nowMs)).toBe(false);
|
|
});
|
|
|
|
it("suppresses manual retry for future automatic recovery in active columns", () => {
|
|
for (const column of ["todo", "in-progress"] as const) {
|
|
const task = makeTask({
|
|
column,
|
|
recoveryRetryCount: 1,
|
|
nextRecoveryAt: new Date(nowMs + 60_000).toISOString(),
|
|
});
|
|
expect(hasPendingAutomaticRecovery(task, nowMs), column).toBe(true);
|
|
expect(isTaskManuallyRetryable(task, nowMs), column).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("lets a future schedule win over a defensive stale failed status", () => {
|
|
const task = makeTask({
|
|
status: "failed",
|
|
recoveryRetryCount: 1,
|
|
nextRecoveryAt: new Date(nowMs + 60_000).toISOString(),
|
|
});
|
|
|
|
expect(hasPendingAutomaticRecovery(task, nowMs)).toBe(true);
|
|
expect(isTaskManuallyRetryable(task, nowMs)).toBe(false);
|
|
});
|
|
|
|
it("falls back to terminal retry states after recovery is elapsed or unscheduled", () => {
|
|
expect(isTaskManuallyRetryable(makeTask({
|
|
status: "failed",
|
|
recoveryRetryCount: 1,
|
|
nextRecoveryAt: new Date(nowMs - 60_000).toISOString(),
|
|
}), nowMs)).toBe(true);
|
|
expect(isTaskManuallyRetryable(makeTask({ status: "failed", recoveryRetryCount: 1 }), nowMs)).toBe(true);
|
|
expect(isTaskManuallyRetryable(makeTask({ status: "failed", recoveryRetryCount: null, nextRecoveryAt: null }), nowMs)).toBe(true);
|
|
});
|
|
|
|
it("keeps established terminal retry states retryable without a pending schedule", () => {
|
|
for (const task of [
|
|
makeTask({ status: "stuck-killed" }),
|
|
makeTask({ status: "needs-replan" }),
|
|
makeTask({ status: "planning" }),
|
|
makeTask({ stuckKillCount: 1 }),
|
|
]) {
|
|
expect(isTaskManuallyRetryable(task, nowMs)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("never infers retryability for done or archived tasks without terminal state", () => {
|
|
expect(isTaskManuallyRetryable(makeTask({ column: "done" }), nowMs)).toBe(false);
|
|
expect(isTaskManuallyRetryable(makeTask({ column: "archived" }), nowMs)).toBe(false);
|
|
});
|
|
});
|