Skip to content

Commit 16a7e9a

Browse files
authored
fix(resume): answer a sync v2 resume whose run fails with the failed run, not a 500 (#9047)
A synchronous resume whose resumed run failed in-band (a block error) let the engine's error escape executeResumeWorkflow, and the v2 resume route mapped it to INTERNAL_ERROR. The sync branch now settles an error that carries the run's partial result as a failed sync result classified like a sync execute; errors with no run behind them still throw. The legacy resume route keeps its 400 error body for a failed run.
1 parent bca7836 commit 16a7e9a

5 files changed

Lines changed: 193 additions & 9 deletions

File tree

‎apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ vi.mock('@/lib/workspaces/utils', () => workspacesUtilsMock)
3838

3939
vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => humanInTheLoopManagerMock)
4040

41+
import { executeResumeWorkflow } from '@/lib/workflows/executor/resume-execution'
4142
import { POST } from '@/app/api/resume/[workflowId]/[executionId]/[contextId]/route'
4243
import { handleResumeExecution } from '@/app/api/resume/resume-handler'
44+
import { attachExecutionResult, buildBlockExecutionError } from '@/executor/utils/errors'
45+
import type { SerializedBlock } from '@/serializer/types'
4346

44-
const { mockEnqueueOrStartResume, mockGetPausedExecutionDetail } = humanInTheLoopManagerMockFns
47+
const { mockEnqueueOrStartResume, mockGetPausedExecutionDetail, mockStartResumeExecution } =
48+
humanInTheLoopManagerMockFns
4549
const { mockGetWorkspaceBilledAccountUserId: mockGetCurrentPayer } = workspacesUtilsMockFns
4650

4751
const { mockShouldExecuteInline } = asyncJobsMockFns
@@ -362,6 +366,102 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => {
362366
)
363367
})
364368

369+
describe('a synchronous resume whose run fails', () => {
370+
const STARTED_RESUME = {
371+
status: 'started',
372+
resumeExecutionId: 'resume-execution-1',
373+
resumeEntryId: 'resume-entry-1',
374+
pausedExecution: { id: 'paused-execution-1' },
375+
contextId: CONTEXT_ID,
376+
resumeInput: { approved: true },
377+
userId: 'current-api-key-user',
378+
}
379+
380+
/** The shape the engine throws when a block fails mid-run: block context plus the partial result. */
381+
function inBandRunFailure() {
382+
const conditionBlock = {
383+
id: 'condition-1',
384+
metadata: { id: 'condition', name: 'Route' },
385+
} as SerializedBlock
386+
const error = buildBlockExecutionError({
387+
block: conditionBlock,
388+
error: new Error('Condition evaluation failed'),
389+
})
390+
attachExecutionResult(error, {
391+
success: false,
392+
output: { partial: true },
393+
error: error.message,
394+
logs: [],
395+
metadata: {
396+
startTime: '2026-08-05T00:00:00.000Z',
397+
endTime: '2026-08-05T00:00:01.000Z',
398+
duration: 1000,
399+
},
400+
})
401+
return error
402+
}
403+
404+
function resumeOnV2Surface() {
405+
return executeResumeWorkflow({
406+
workflowId: WORKFLOW_ID,
407+
executionId: EXECUTION_ID,
408+
contextId: CONTEXT_ID,
409+
workspaceId: WORKSPACE_ID,
410+
userId: 'current-api-key-user',
411+
resumeInput: { approved: true },
412+
isApiCaller: true,
413+
pollingSurface: 'v2',
414+
allowStreaming: false,
415+
})
416+
}
417+
418+
beforeEach(() => {
419+
mockEnqueueOrStartResume.mockResolvedValueOnce(STARTED_RESUME)
420+
})
421+
422+
it('settles an in-band failure as a failed run with a classified error', async () => {
423+
mockStartResumeExecution.mockRejectedValueOnce(inBandRunFailure())
424+
425+
await expect(resumeOnV2Surface()).resolves.toEqual({
426+
kind: 'sync',
427+
executionId: 'resume-execution-1',
428+
success: false,
429+
status: 'failed',
430+
output: { partial: true },
431+
error: 'Route: Condition evaluation failed',
432+
structuredError: {
433+
message: 'Condition evaluation failed',
434+
code: 'BLOCK_EXECUTION_FAILED',
435+
blockId: 'condition-1',
436+
blockName: 'Route',
437+
blockType: 'condition',
438+
},
439+
metadata: {
440+
startTime: '2026-08-05T00:00:00.000Z',
441+
endTime: '2026-08-05T00:00:01.000Z',
442+
duration: 1000,
443+
},
444+
})
445+
})
446+
447+
it('still throws a failure that produced no run', async () => {
448+
const infrastructureFailure = new Error('Run buffer unavailable')
449+
mockStartResumeExecution.mockRejectedValueOnce(infrastructureFailure)
450+
451+
await expect(resumeOnV2Surface()).rejects.toBe(infrastructureFailure)
452+
})
453+
454+
it('keeps the legacy route answering an in-band failure with its 400 error body', async () => {
455+
mockStartResumeExecution.mockRejectedValueOnce(inBandRunFailure())
456+
const { request, context } = makeRequest()
457+
458+
const response = await POST(request, context)
459+
460+
expect(response.status).toBe(400)
461+
expect(await response.json()).toEqual({ error: 'Route: Condition evaluation failed' })
462+
})
463+
})
464+
365465
it.each([
366466
{ statusCode: 402, message: 'Member usage limit reached', retryable: false },
367467
{ statusCode: 429, message: 'Target concurrency full', retryable: true },

‎apps/sim/app/api/resume/resume-handler.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ function presentResumeResult(
5050
},
5151
})
5252
case 'sync':
53+
/** This surface has always answered a failed run with a 400 error body, never a run payload. */
54+
if (result.status === 'failed') {
55+
return NextResponse.json(
56+
{ error: typeof result.error === 'string' ? result.error : 'Resume execution failed' },
57+
{ status: 400 }
58+
)
59+
}
5360
return NextResponse.json({
5461
success: result.success,
5562
status: result.status,

‎apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/resume', () => {
144144
executionId: 'resume-execution-3',
145145
output: { approved: true },
146146
error: undefined,
147+
structuredError: null,
147148
metadata: {
148149
startTime: '2026-08-05T00:00:00.000Z',
149150
endTime: '2026-08-05T00:00:01.000Z',
@@ -172,6 +173,55 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/resume', () => {
172173
expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body)
173174
})
174175

176+
it('answers a resume whose run failed in-band with the failed run resource', async () => {
177+
mocks.resume.mockResolvedValueOnce({
178+
kind: 'sync',
179+
success: false,
180+
status: 'failed',
181+
executionId: 'resume-execution-4',
182+
output: { partial: true },
183+
error: 'Route: Condition evaluation failed',
184+
structuredError: {
185+
message: 'Condition evaluation failed',
186+
code: 'BLOCK_EXECUTION_FAILED',
187+
blockId: 'condition-1',
188+
blockName: 'Route',
189+
blockType: 'condition',
190+
},
191+
metadata: {
192+
startTime: '2026-08-05T00:00:00.000Z',
193+
endTime: '2026-08-05T00:00:01.000Z',
194+
duration: 1000,
195+
},
196+
})
197+
const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-4' }))
198+
199+
const response = await POST(request, context)
200+
const body = await response.json()
201+
202+
expect(response.status).toBe(200)
203+
expect(body).toEqual({
204+
data: {
205+
runId: 'resume-execution-4',
206+
workflowId: WORKFLOW_ID,
207+
status: 'failed',
208+
output: { partial: true },
209+
blockOutputs: null,
210+
error: {
211+
message: 'Condition evaluation failed',
212+
code: 'BLOCK_EXECUTION_FAILED',
213+
blockId: 'condition-1',
214+
blockName: 'Route',
215+
blockType: 'condition',
216+
},
217+
startedAt: '2026-08-05T00:00:00.000Z',
218+
endedAt: '2026-08-05T00:00:01.000Z',
219+
durationMs: 1000,
220+
},
221+
})
222+
expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body)
223+
})
224+
175225
it('returns a safe error when the resume manager fails', async () => {
176226
mocks.resume.mockRejectedValueOnce(new Error('resume database connection details'))
177227
const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-6' }))

‎apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { workflowOperations } from '@/lib/workflows/application/operations'
2323
import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run'
2424
import { ResumeWorkflowExecutionError } from '@/lib/workflows/executor/resume-execution'
2525
import { v2Data, v2Error } from '@/app/api/v2/lib/response'
26-
import { classifyExecutionError } from '@/executor/utils/errors'
2726

2827
const logger = createLogger('V2WorkflowResumeAPI')
2928

@@ -114,10 +113,7 @@ export const POST = withRouteHandler(
114113
output: result.output ?? null,
115114
// Resume has no request body to name selectors in, so selection never applies here.
116115
blockOutputs: null,
117-
error:
118-
typeof result.error === 'string'
119-
? classifyExecutionError(new Error(result.error))
120-
: null,
116+
error: result.structuredError,
121117
startedAt: result.metadata?.startTime,
122118
endedAt: result.metadata?.endTime,
123119
durationMs: result.metadata?.duration,

‎apps/sim/lib/workflows/executor/resume-execution.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-m
1616
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
1717
import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution'
1818
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
19+
import type { ExecutionResult } from '@/executor/types'
20+
import {
21+
classifyExecutionError,
22+
hasExecutionResult,
23+
type StructuredExecutionError,
24+
} from '@/executor/utils/errors'
1925
import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
2026

2127
const logger = createLogger('WorkflowResumeExecution')
@@ -71,6 +77,8 @@ export type ResumeWorkflowExecutionResult =
7177
status: string
7278
output: unknown
7379
error: unknown
80+
/** The run's failure classified the way a sync execute classifies it; null when it did not fail. */
81+
structuredError: StructuredExecutionError | null
7482
metadata?: {
7583
duration?: number
7684
startTime?: string
@@ -298,14 +306,37 @@ export async function executeResumeWorkflow({
298306
}
299307

300308
if (isApiCaller && executionMode === 'sync') {
301-
const result = await PauseResumeManager.startResumeExecution(resumeArgs)
309+
let result: ExecutionResult
310+
let runFailure: unknown
311+
try {
312+
result = await PauseResumeManager.startResumeExecution(resumeArgs)
313+
} catch (error) {
314+
/**
315+
* A run that started and then failed carries its partial result: that is the run's
316+
* answer, not a failed request. Anything else produced no run and stays an exception.
317+
*/
318+
if (!hasExecutionResult(error)) throw error
319+
result = error.executionResult
320+
runFailure = error
321+
}
322+
const status =
323+
runFailure !== undefined
324+
? 'failed'
325+
: (result.status ?? (result.success ? 'completed' : 'failed'))
302326
return {
303327
kind: 'sync',
304328
executionId: enqueueResult.resumeExecutionId,
305-
success: result.success,
306-
status: result.status ?? (result.success ? 'completed' : 'failed'),
329+
success: runFailure === undefined && result.success,
330+
status,
307331
output: result.output,
308332
error: result.error,
333+
structuredError:
334+
status === 'failed' || (status === 'cancelled' && result.error)
335+
? classifyExecutionError(
336+
runFailure ?? (result.error ? new Error(result.error) : undefined),
337+
result
338+
)
339+
: null,
309340
metadata: result.metadata
310341
? {
311342
duration: result.metadata.duration,

0 commit comments

Comments
 (0)