fix(editor): Reconcile runs that parked into wait on push reconnect

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 <noreply@anthropic.com>
This commit is contained in:
palette 2026-07-21 14:54:12 +03:00
parent b01313c0a9
commit 22ef44cf96
2 changed files with 58 additions and 12 deletions

View File

@ -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<IWorkflowDb>({ 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);

View File

@ -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 {