From 22ef44cf964cd7cdfbd40ef5b450fe029a545e08 Mon Sep 17 00:00:00 2001 From: palette Date: Tue, 21 Jul 2026 14:54:12 +0300 Subject: [PATCH] fix(editor): Reconcile runs that parked into wait on push reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exclude `waiting` from the active-status query used to detect a stranded run on reconnect. A run that parks into wait server-side (`data.waitTill`) sends its own push, so a disconnect across that transition strands the running spinner exactly like a dropped finish. With `waiting` excluded from the query, such a run falls out of the "still running" set and is routed through `reconcileFinishedExecution`, whose existing waitTill branch clears `activeExecutionId` and renders the wait state — mirroring the normal push path. `new` and `running` remain, so genuinely executing runs are still left untouched. Adds a regression test asserting a run parked into wait while disconnected has its spinner cleared and the wait execution rendered after reconnect. Co-Authored-By: Claude Opus 4.8 --- .../handlers/reconcileOnReconnect.test.ts | 44 ++++++++++++++++++- .../handlers/reconcileOnReconnect.ts | 26 ++++++----- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.test.ts b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.test.ts index 67340d30181..8c8657f88cd 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.test.ts @@ -95,6 +95,24 @@ function makeFinishedExecution(executionId: string): IExecutionResponse { }; } +function makeWaitingExecution(executionId: string): IExecutionResponse { + const data = createRunExecutionData({ resultData: { runData: {} } }); + // A run parked into wait server-side carries `waitTill` on its run data; the + // reconciliation routes this through the wait branch (mirrors the push path). + data.waitTill = new Date('2999-01-01T00:00:00.000Z'); + return { + id: executionId, + workflowId, + finished: false, + mode: 'manual', + status: 'waiting', + startedAt: new Date(), + createdAt: new Date(), + workflowData: mock({ id: workflowId, nodes: [], connections: {} }), + data, + }; +} + describe('reconcileExecutionStateOnReconnect', () => { beforeEach(() => { vi.clearAllMocks(); @@ -118,7 +136,7 @@ describe('reconcileExecutionStateOnReconnect', () => { expect(getActiveExecutionsMock).toHaveBeenCalledWith(expect.anything(), { workflowId, - status: ['new', 'running', 'waiting'], + status: ['new', 'running'], }); // State reconciled to server truth: spinner cleared, final run displayed. expect(executionStateStore.isWorkflowRunning).toBe(false); @@ -126,6 +144,30 @@ describe('reconcileExecutionStateOnReconnect', () => { expect(executionStateStore.displayedExecutionId).toBe(executionId); }); + it('clears the stranded spinner when the tracked run parked into wait while disconnected', async () => { + const executionId = 'exec-parked-offline'; + const executionStateStore = startRunningExecution(executionId); + expect(executionStateStore.isWorkflowRunning).toBe(true); + + // `waiting` runs are excluded from the active-status query, so the server + // does not return the parked run — its `waitTill` push was dropped. + getActiveExecutionsMock.mockResolvedValue([]); + vi.spyOn(useWorkflowsStore(), 'fetchExecutionDataById').mockResolvedValue( + makeWaitingExecution(executionId), + ); + + await reconcileExecutionStateOnReconnect(options); + + expect(getActiveExecutionsMock).toHaveBeenCalledWith(expect.anything(), { + workflowId, + status: ['new', 'running'], + }); + // Reconciled to the wait state: spinner cleared, wait execution displayed. + expect(executionStateStore.isWorkflowRunning).toBe(false); + expect(executionStateStore.activeExecutionId).toBeUndefined(); + expect(executionStateStore.displayedExecutionId).toBe(executionId); + }); + it('leaves a genuinely running execution untouched on reconnect', async () => { const executionId = 'exec-still-running'; const executionStateStore = startRunningExecution(executionId); diff --git a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.ts b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.ts index aff4e36913f..9da80dcd0de 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/reconcileOnReconnect.ts @@ -7,22 +7,26 @@ import { reconcileFinishedExecution } from './executionRecovered'; import type { PushHandlerOptions } from './types'; /** - * Server-side statuses that still count as an in-flight run. If the execution - * this document is tracking is not returned under one of these, it has reached - * a terminal state on the server. + * Server-side statuses for which the canvas should keep rendering a live, + * executing run. `waiting` is deliberately excluded: a run that parks into wait + * server-side (`data.waitTill`) sends its own push, so a disconnect across that + * transition strands the running spinner just like a dropped finish. Excluding + * `waiting` routes such a run through reconciliation, where the re-fetched + * execution hits the existing waitTill branch and renders the wait state. */ -const ACTIVE_EXECUTION_STATUSES: ExecutionStatus[] = ['new', 'running', 'waiting']; +const RUNNING_EXECUTION_STATUSES: ExecutionStatus[] = ['new', 'running']; /** * Reconciles the canvas execution state against server truth after a push * reconnect. * - * If the client disconnects across the moment a run finishes (or a single - * `executionFinished` frame is lost), that push is never delivered and the - * canvas is stranded showing a running spinner. On reconnect we ask the server - * which executions are still active for this workflow; if the one this document - * is tracking is no longer among them, it finished while we were away, so we - * re-fetch it and render the final state — which also clears the spinner. + * If the client disconnects across the moment a run finishes or parks into wait + * (or a single `executionFinished` frame is lost), that push is never delivered + * and the canvas is stranded showing a running spinner. On reconnect we ask the + * server which executions are still actively running for this workflow; if the + * one this document is tracking is no longer among them, it finished or parked + * while we were away, so we re-fetch it and render the final/wait state — which + * also clears the spinner. * * A run that is genuinely still executing server-side is left untouched, so * live execution rendering during a normal, uninterrupted run is unaffected. @@ -44,7 +48,7 @@ export async function reconcileExecutionStateOnReconnect(options: PushHandlerOpt try { const activeExecutions = await getActiveExecutions(rootStore.restApiContext, { workflowId, - status: ACTIVE_EXECUTION_STATUSES, + status: RUNNING_EXECUTION_STATUSES, }); isStillActive = activeExecutions.some((execution) => execution.id === activeExecutionId); } catch {