1 Commits

Author SHA1 Message Date
51b51818b3 fix(tasks): stop TaskRunner refresh loop for FAILED task without result
A FAILED task with result == null (e.g. Mapper LLM preview with no provider
configured) made the selectedTask subscriber call refreshTaskDetails, whose
selectedTask.set re-fired the subscriber, looping forever and reopening the
WebSocket / refetching logs each pass. Re-initialize only when the task id
changes and run the detail refresh at most once per task id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:23:53 +03:00
2 changed files with 71 additions and 6 deletions

View File

@@ -30,6 +30,10 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
let maxReconnectDelay = 30000;
let reconnectTimeout;
let destroyed = false;
// Task id whose connection/log stream was initialized; same-id store updates must not re-init.
let initializedTaskId: string | null = null;
// Task id whose detail refresh already ran; FAILED tasks may legitimately keep result == null.
let detailsRefreshedForTaskId: string | null = null;
let waitingForData = $state(false);
let dataTimeout;
let connectionStatus = $state('disconnected'); // 'connecting', 'connected', 'disconnected', 'waiting', 'completed', 'failed', 'awaiting_mapping', 'awaiting_input'
@@ -150,7 +154,7 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
if (st === 'SUCCESS' || st === 'FAILED' || st === 'ERROR' || st === 'COMPLETED') {
connectionStatus = st === 'FAILED' || st === 'ERROR' ? 'failed' : 'completed';
selectedTask.set({ ...selectedTask.current, ...logEntry.task });
void refreshTaskDetails();
refreshTaskDetailsOnce();
try { ws.close(); } catch { /* ignore */ }
}
}
@@ -223,6 +227,8 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
// #region Tasks.TaskRunner.RefreshTaskDetails [C:3] [TYPE Function] [SEMANTICS task,status,refresh]
// @ingroup Tasks
// @BRIEF Reload task from API so structured plugin result is available for TaskResultPanel.
// @POST selectedTask is replaced with the API copy; callers must go through
// refreshTaskDetailsOnce so the resulting store notification cannot re-trigger a refresh.
async function refreshTaskDetails() {
const task = selectedTask.current;
if (!task?.id) return;
@@ -249,6 +255,20 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
}
// #endregion Tasks.TaskRunner.RefreshTaskDetails
// #region Tasks.TaskRunner.RefreshTaskDetailsOnce [C:2] [TYPE Function] [SEMANTICS task,refresh,guard]
// @ingroup Tasks
// @BRIEF Run refreshTaskDetails at most once per task id.
// @RATIONALE A FAILED task without structured result stays result == null after reload;
// an unguarded refresh -> selectedTask.set -> subscriber -> refresh cycle loops forever.
// @POST detailsRefreshedForTaskId === selectedTask.current.id.
function refreshTaskDetailsOnce() {
const id = selectedTask.current?.id ?? null;
if (!id || detailsRefreshedForTaskId === id) return;
detailsRefreshedForTaskId = id;
void refreshTaskDetails();
}
// #endregion Tasks.TaskRunner.RefreshTaskDetailsOnce
// #region Tasks.TaskRunner.HandleFilterChangeFunction [TYPE Function]
// @LAYER UI
/**
@@ -397,17 +417,39 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
* @purpose Initializes WebSocket connection when component mounts.
* @pre Component must be mounted in DOM.
* @post WebSocket connection established, subscription to selectedTask active.
* @post Connection/log re-initialization happens only when the selected task id changes;
* same-id updates only sync terminal status. Detail refresh runs at most once per task id.
*/
onMount(() => {
destroyed = false;
const unsubscribe = selectedTask.subscribe(task => {
if (!task) {
initializedTaskId = null;
detailsRefreshedForTaskId = null;
return;
}
if (task) {
const st = String(task.status || '').toUpperCase();
const isTerminal = ['SUCCESS', 'FAILED', 'COMPLETED', 'ERROR'].includes(st);
const terminalConnectionStatus = ['SUCCESS', 'COMPLETED'].includes(st)
? 'completed'
: ['FAILED', 'ERROR'].includes(st)
? 'failed'
: null;
if (task.id && task.id === initializedTaskId) {
// Same task updated (detail refresh or caller polling): no re-init.
if (terminalConnectionStatus) {
connectionStatus = terminalConnectionStatus;
clearTimeout(reconnectTimeout);
if (ws) { try { ws.close(); } catch { /* ignore */ } }
if (task.result == null) refreshTaskDetailsOnce();
}
return;
}
initializedTaskId = task.id ?? null;
detailsRefreshedForTaskId = null;
log("TaskRunner", "REASON", "Task selected, initializing connection", { taskId: task.id });
if (terminalConnectionStatus) connectionStatus = terminalConnectionStatus;
if (ws) ws.close();
@@ -446,11 +488,8 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
}
// Terminal tasks without structured result: reload detail for TaskResultPanel.
if (
['SUCCESS', 'FAILED', 'COMPLETED', 'ERROR'].includes(st) &&
(task.result == null || task.result === undefined)
) {
void refreshTaskDetails();
if (isTerminal && task.result == null) {
refreshTaskDetailsOnce();
}
connect();

View File

@@ -4,11 +4,13 @@
// @RELATION VERIFIES -> [Tasks.TaskRunner]
// @TEST_INVARIANT FAILED and ERROR task statuses render failure and never reconnect.
// @TEST_INVARIANT Ordinary log text containing FAILED does not terminate the task.
// @TEST_INVARIANT A FAILED task without structured result refreshes details once and never loops.
import { render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { selectedTask, taskLogs } from '$lib/stores/selectedTask.svelte.js';
import TaskRunner from '../TaskRunner.svelte';
import { api } from '$lib/api';
vi.mock('$lib/api', () => ({
getWsUrl: (taskId: string) => `ws://example.test/tasks/${taskId}`,
@@ -141,6 +143,30 @@ describe('TaskRunner structured terminal status', () => {
expect(FakeWebSocket.instances).toHaveLength(1);
});
it('refreshes a FAILED task without result exactly once (no refresh loop)', async () => {
vi.mocked(api.getTask).mockClear();
vi.mocked(api.getTaskLogs).mockClear();
vi.mocked(api.getTask).mockImplementation(async (taskId: string) => ({
id: taskId,
status: 'FAILED',
params: {},
result: null,
}));
selectedTask.set({ id: 'task-no-result', status: 'FAILED', params: {}, result: null });
render(TaskRunner);
expect(await screen.findByText('Failed')).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 200));
// Caller (e.g. Mapper polling) re-sets the same task: must not re-initialize.
selectedTask.set({ id: 'task-no-result', status: 'FAILED', params: {}, result: null });
await new Promise((resolve) => setTimeout(resolve, 200));
expect(api.getTask).toHaveBeenCalledTimes(1);
expect(api.getTaskLogs).toHaveBeenCalledTimes(1);
expect(FakeWebSocket.instances).toHaveLength(0);
});
it('explains a recovered migration after an intermediate import error', async () => {
selectedTask.set({
id: 'task-recovered',