From 5d98041bb4e73c5d1f28ae084851eea5731151b6 Mon Sep 17 00:00:00 2001 From: lakshya-dhariwal Date: Mon, 28 Sep 2026 13:24:36 +0530 Subject: [PATCH] fix(v2): await run log finalization before sync execute responds The v2 sync execute route returned 'completed' while the run log was still being finalized: execution-core defers status/endedAt/cost persistence into the logging session's post-execution promise, and nothing on the service's sync path awaited it. A GET on the run's log right after the response could still show status 'running', a null endedAt, and missing cost items. Hold the sync response until the post-execution promise settles, so the terminal response doubles as a durable receipt. Async (queued) runs are unaffected; they already return before execution starts. fixes #8354 --- .../[workflowId]/execute/route.test.ts | 34 +++++++++++++++++++ .../lib/workflows/executor/execute-service.ts | 5 +++ 2 files changed, 39 insertions(+) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 92cd87f0dbb..4282397e197 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -4,6 +4,7 @@ import { executionPreprocessingMock, executionPreprocessingMockFns, loggingSessionMock, + loggingSessionMockFns, resetDbChainMock, setEnv, workflowAuthzMockFns, @@ -329,6 +330,39 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { }) }) + it('holds the sync response until post-execution logging finalizes', async () => { + let releaseFinalization!: () => void + loggingSessionMockFns.mockWaitForPostExecution.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFinalization = resolve + }) + ) + + let responded = false + const pending = callExecute({ input: { hello: 'world' } }).then((res) => { + responded = true + return res + }) + + // Wait until execution is actually parked at the finalization gate, then + // prove the gate is what holds the response back. + await vi.waitFor(() => + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled() + ) + expect(responded).toBe(false) + + releaseFinalization() + const res = await pending + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toMatchObject({ + runId: 'execution-123', + status: 'completed', + output: { result: 'done' }, + }) + }) + it('streams an immediate heartbeat and the same sync result when NDJSON is accepted', async () => { vi.useFakeTimers() try { diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 5f6d7363863..5423bbe8e1f 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -877,6 +877,11 @@ export async function executeWorkflowService( } finally { abortSignal?.removeEventListener('abort', abortFromRequest) timeoutController.cleanup() + // The sync response doubles as the run's terminal receipt: callers read + // the log (status, endedAt) and cost ledger as soon as it lands. The + // core defers that finalization into the session's post-execution + // promise, so hold the response until the log is final. + await loggingSession.waitForPostExecution() } }