修复任务列表首次加载失败后停止轮询

为任务列表 bootstrap 增加一秒、两秒和四秒的有界退避重试
瞬时失败时保留已有任务并在恢复 active ID 后启动钱包轮询
覆盖重试恢复与重试耗尽边界
同步更新账户钱包轮询基线
This commit is contained in:
2026-08-06 11:09:59 +08:00
parent f18e0aef4f
commit 62005f1313
3 changed files with 166 additions and 50 deletions
@@ -615,6 +615,104 @@ describe('ImageCanvasTaskSidebarView', () => {
}
});
it('retries a failed bootstrap and starts wallet polling from the recovered active task', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
const runningTask = createExternalTask({
jobId: 'bootstrap-recovered-task',
status: 'running',
});
let activeRequestCount = 0;
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
if (activeRequestCount === 1) {
return Promise.reject(new Error('temporary bootstrap failure'));
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 1,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [runningTask],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2);
expect(activeRequestCount).toBe(3);
} finally {
vi.useRealTimers();
}
});
it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => {
vi.useFakeTimers();
try {
listExternalGenerationTasksMock.mockRejectedValue(
new Error('task list unavailable'),
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(8);
} finally {
vi.useRealTimers();
}
});
it('loads more completed tasks while scrolling and keeps the request capped', async () => {
const completedTasks = Array.from({ length: 120 }, (_, index) => {
const completedAt = new Date(Date.now() - index * 1000).toISOString();
@@ -26,6 +26,7 @@ const COMPLETED_TASK_LIST_LIMIT = 20;
const COMPLETED_TASK_LIST_MAX_LIMIT = 100;
const ACTIVE_TASK_LIST_LIMIT = 100;
const ACTIVE_TASK_POLL_INTERVAL_MS = 4000;
const TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000] as const;
type TaskSidebarTab = 'active' | 'completed';
@@ -357,58 +358,75 @@ export function ImageCanvasTaskSidebarView({
useEffect(() => {
let disposed = false;
const controller = new AbortController();
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
return;
}
const activeTasks = activeResponse.tasks.filter(isActiveExternalTask);
const visibleActiveTasks = filterVisibleExternalTasks(activeTasks);
const visibleCompletedTasks = filterVisibleExternalTasks(
completedResponse.tasks,
);
setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId));
if (
activeTasks.length > 0 ||
completedResponse.tasks.some(
(task) =>
isTerminalExternalTask(task) && !task.notificationAcknowledgedAt,
)
) {
onExternalTaskWalletMayHaveChanged?.();
}
notifyCompletedExternalTasks(visibleCompletedTasks, {
unacknowledgedOnly: true,
let controller: AbortController | null = null;
let retryTimerId: number | null = null;
let retryIndex = 0;
const loadTaskLists = () => {
controller = new AbortController();
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
return;
}
const activeTasks = activeResponse.tasks.filter(isActiveExternalTask);
const visibleActiveTasks = filterVisibleExternalTasks(activeTasks);
const visibleCompletedTasks = filterVisibleExternalTasks(
completedResponse.tasks,
);
setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId));
if (
activeTasks.length > 0 ||
completedResponse.tasks.some(
(task) =>
isTerminalExternalTask(task) &&
!task.notificationAcknowledgedAt,
)
) {
onExternalTaskWalletMayHaveChanged?.();
}
notifyCompletedExternalTasks(visibleCompletedTasks, {
unacknowledgedOnly: true,
});
setExternalTasks(
trimStoredExternalTasks(
mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks),
completedListLimit,
),
);
})
.catch(() => {
if (
disposed ||
retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length
) {
return;
}
const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex];
retryIndex += 1;
retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs);
});
setExternalTasks(
trimStoredExternalTasks(
mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks),
completedListLimit,
),
);
})
.catch(() => {
if (!disposed) {
setExternalTasks([]);
}
});
};
loadTaskLists();
return () => {
disposed = true;
controller.abort();
controller?.abort();
if (retryTimerId !== null) {
window.clearTimeout(retryTimerId);
}
};
}, [
completedListLimit,