Compare commits
1 Commits
master
...
worktree-f
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b51818b3 |
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user