Skip to content

Commit 5a1a4f3

Browse files
authored
fix(tables): settle a resume whose pause cannot be saved as failed, and never fail a completed run (#8417)
* fix(tables): settle a resume whose pause cannot be saved as failed, and never fail a completed run A resumed run that paused but whose pause state could not be persisted failed its log yet returned a paused result, so the cell showed paused and the resume entry was marked completed. It now throws after failing the log, so the attempt settles as failed and reports execution_failed. markResumeFailed also rewrote a completed log as failed when a step after a completed run threw. A completed run's outcome now stands, and an already failed log keeps its original end time. * fix(tables): settle the cell as completed when a completed resume's later step throws Leaving a completed log alone reported no outcome, so the cell stayed on its last running state. markResumeFailed now reports what the attempt left the execution as, and a run that completed before a later step threw settles the cell as completed. The pause point is still marked failed as before. * fix(tables): continue the cascade after a completed resume whose later step threw A completed run's cell is completed, so its downstream workflow groups still start; the failure is still rethrown. A pause that cannot be saved now throws a stable message with the underlying error on cause, so API callers never see internal persistence details. * fix(tables): keep server-side logging for a resumed pause that cannot be saved * fix(tables): continue the cascade only once the completed cell is saved * test(tables): assert which downstream groups a resume's cascade started
1 parent ed8b482 commit 5a1a4f3

4 files changed

Lines changed: 229 additions & 68 deletions

File tree

‎apps/sim/background/resume-execution.ts‎

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,36 @@ export async function executeResumeJob(payload: ResumeExecutionPayload, signal?:
178178
cellContext.rowId,
179179
parentExecutionId,
180180
async () => {
181+
let completedBeforeFailure = false
181182
const result = await runResumeAndCellTerminal(
182183
payload,
183184
pausedExecution,
184185
writers,
185186
attemptSignal,
186-
attemptTimeoutController
187-
)
187+
attemptTimeoutController,
188+
() => {
189+
completedBeforeFailure = true
190+
}
191+
).catch(async (error: unknown) => {
192+
/**
193+
* The run completed and only a later step of the attempt threw, so its
194+
* cell is completed: continue the cascade as a completed run would, and
195+
* still surface the failure.
196+
*/
197+
if (completedBeforeFailure) {
198+
await continueCascadeAfterResume(cellContext, billingAttribution, attemptSignal).catch(
199+
(cascadeError: unknown) => {
200+
logger.error(
201+
'Failed to continue the cascade after a completed resume',
202+
projectResolvedSecretDiagnosticError(cascadeError, undefined, {
203+
resumeExecutionId,
204+
})
205+
)
206+
}
207+
)
208+
}
209+
throw error
210+
})
188211
if (result.status === 'paused' || result.status === 'cancelled') return result
189212
await continueCascadeAfterResume(cellContext, billingAttribution, attemptSignal)
190213
return result
@@ -364,19 +387,26 @@ async function buildResumeCellWriters(
364387
/**
365388
* A resume that throws never reaches the terminal write in
366389
* {@link runResumeAndCellTerminal}, which would leave the cell showing its last
367-
* partial `running` state. Mirror what the failed attempt did to the execution:
368-
* a pause that stayed resumable goes back to paused, and a failed execution
369-
* fails the cell.
390+
* partial `running` state. Mirror what the failed attempt left the execution
391+
* as: a pause that stayed resumable goes back to paused, a failed execution
392+
* fails the cell, and a run that completed before a later step threw
393+
* completes it.
370394
*/
371395
async function writeFailedResumeCellTerminal(
372396
writers: CellWriters,
373397
outcome: FailedResumeOutcome,
374398
error: unknown
375399
): Promise<void> {
376-
if (outcome === 'pause_retained') {
377-
await writers.writeCellTerminal('paused', null)
378-
} else {
379-
await writers.writeCellTerminal('error', getErrorMessage(error, 'Resume execution failed'))
400+
switch (outcome) {
401+
case 'pause_retained':
402+
await writers.writeCellTerminal('paused', null)
403+
return
404+
case 'execution_completed':
405+
await writers.writeCellTerminal('completed', null)
406+
return
407+
case 'execution_failed':
408+
await writers.writeCellTerminal('error', getErrorMessage(error, 'Resume execution failed'))
409+
return
380410
}
381411
}
382412

@@ -385,7 +415,8 @@ async function runResumeAndCellTerminal(
385415
pausedExecution: Awaited<ReturnType<typeof PauseResumeManager.getPausedExecutionById>>,
386416
writers: CellWriters,
387417
signal: AbortSignal | undefined,
388-
timeoutController: ReturnType<typeof createTimeoutAbortController>
418+
timeoutController: ReturnType<typeof createTimeoutAbortController>,
419+
onCompletedBeforeFailure?: () => void
389420
): Promise<Awaited<ReturnType<typeof PauseResumeManager.startResumeExecution>>> {
390421
if (!pausedExecution) throw new Error('Paused execution missing — already nulled by caller')
391422
const result = await PauseResumeManager.startResumeExecution({
@@ -396,7 +427,11 @@ async function runResumeAndCellTerminal(
396427
resumeInput: payload.resumeInput,
397428
userId: payload.userId,
398429
onBlockComplete: writers.cellOnBlockComplete,
399-
onAttemptFailed: (outcome, error) => writeFailedResumeCellTerminal(writers, outcome, error),
430+
onAttemptFailed: async (outcome, error) => {
431+
await writeFailedResumeCellTerminal(writers, outcome, error)
432+
/** Only a cell saved as completed may start its downstream groups. */
433+
if (outcome === 'execution_completed') onCompletedBeforeFailure?.()
434+
},
400435
abortSignal: signal,
401436
})
402437

‎apps/sim/background/resume-governed-subject.test.ts‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,21 +220,34 @@ describe('resuming a paused table cell', () => {
220220
}, 20_000)
221221

222222
describe('when the resume throws', () => {
223+
/** Downstream groups the row's cascade started after the resume. */
224+
let startedGroups: string[]
225+
226+
beforeEach(() => {
227+
startedGroups = []
228+
mocks.runRowCascadeLoop.mockImplementation(async (payload: { groupId: string }) => {
229+
startedGroups.push(payload.groupId)
230+
})
231+
})
232+
223233
/** The execution state the last cell write persisted. */
224234
function lastCellExecutionState() {
225235
const [, payload] = mocks.writeWorkflowGroupState.mock.calls.at(-1) ?? []
226236
return payload?.executionState
227237
}
228238

229-
/** Fails the resume the way the manager does: settle, report the outcome, rethrow. */
239+
/**
240+
* Fails the resume the way the manager does: settle, report the outcome (a
241+
* failing handler is logged, never rethrown), rethrow the attempt's error.
242+
*/
230243
function failResume(outcome: FailedResumeOutcome, error: Error) {
231244
mocks.startResumeExecution.mockImplementationOnce(
232245
async ({
233246
onAttemptFailed,
234247
}: {
235248
onAttemptFailed?: (outcome: FailedResumeOutcome, error: unknown) => Promise<void>
236249
}) => {
237-
await onAttemptFailed?.(outcome, error)
250+
await onAttemptFailed?.(outcome, error).catch(() => undefined)
238251
throw error
239252
}
240253
)
@@ -253,6 +266,39 @@ describe('resuming a paused table cell', () => {
253266
})
254267
}, 20_000)
255268

269+
it('marks the cell completed when the run completed before a later step failed', async () => {
270+
const bookkeepingFailure = new Error('Database unavailable')
271+
failResume('execution_completed', bookkeepingFailure)
272+
273+
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(bookkeepingFailure)
274+
275+
expect(lastCellExecutionState()).toMatchObject({
276+
status: 'completed',
277+
executionId: 'parent-execution-1',
278+
error: null,
279+
})
280+
expect(startedGroups).toEqual([NEXT_GROUP.id])
281+
}, 20_000)
282+
283+
it('does not continue the cascade when the completed cell could not be saved', async () => {
284+
const bookkeepingFailure = new Error('Database unavailable')
285+
failResume('execution_completed', bookkeepingFailure)
286+
mocks.writeWorkflowGroupState.mockRejectedValueOnce(new Error('Cell write failed'))
287+
288+
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(bookkeepingFailure)
289+
290+
expect(startedGroups).toEqual([])
291+
}, 20_000)
292+
293+
it('does not continue the cascade when the resume failed the execution', async () => {
294+
const runFailure = new Error('Block failed')
295+
failResume('execution_failed', runFailure)
296+
297+
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(runFailure)
298+
299+
expect(startedGroups).toEqual([])
300+
}, 20_000)
301+
256302
it('puts the cell back to paused when the pause stayed resumable', async () => {
257303
const admissionRefusal = new Error('Execution can no longer be resumed')
258304
failResume('pause_retained', admissionRefusal)

‎apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts‎

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2222
import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core/execution-limits'
2323
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
2424
import { terminalExecutionLogFields } from '@/lib/logs/execution/cancellation'
25+
import { LoggingSession } from '@/lib/logs/execution/logging-session'
2526

2627
const {
2728
mockExecuteWorkflowCore,
@@ -85,7 +86,7 @@ if (!humanInTheLoopLogger) {
8586
}
8687

8788
interface PauseResumeManagerInternals {
88-
markResumeFailed: (...args: unknown[]) => Promise<boolean>
89+
markResumeFailed: (...args: unknown[]) => Promise<FailedResumeOutcome | undefined>
8990
runResumeExecution: (...args: unknown[]) => Promise<unknown>
9091
}
9192

@@ -262,17 +263,36 @@ describe('what a failed resume did to its paused execution', () => {
262263
)
263264

264265
it.each([
265-
{ logStatus: 'running', pauseStatus: 'paused', executionFailed: true },
266-
{ logStatus: 'cancelled', pauseStatus: 'paused', executionFailed: false },
267-
{ logStatus: 'running', pauseStatus: 'cancelling', executionFailed: false },
266+
{ logStatus: 'running', pauseStatus: 'paused', outcome: 'execution_failed' },
267+
{ logStatus: 'cancelled', pauseStatus: 'paused', outcome: undefined },
268+
{ logStatus: 'running', pauseStatus: 'cancelling', outcome: undefined },
269+
{ logStatus: 'failed', pauseStatus: 'paused', outcome: 'execution_failed' },
270+
{ logStatus: 'completed', pauseStatus: 'paused', outcome: 'execution_completed' },
268271
])(
269-
'reports the execution failed: $executionFailed for a $logStatus log and $pauseStatus pause',
270-
async ({ logStatus, pauseStatus, executionFailed }) => {
272+
'reports $outcome for a $logStatus log and $pauseStatus pause',
273+
async ({ logStatus, pauseStatus, outcome }) => {
271274
queueTableRows(workflowExecutionLogs, [{ status: logStatus }])
272275
queueTableRows(pausedExecutions, [{ status: pauseStatus }])
273276
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
274277

275-
await expect(managerInternals.markResumeFailed(attemptArgs)).resolves.toBe(executionFailed)
278+
await expect(managerInternals.markResumeFailed(attemptArgs)).resolves.toBe(outcome)
279+
}
280+
)
281+
282+
it.each([
283+
{ logStatus: 'completed', updated: [resumeQueue, pausedExecutions] },
284+
{ logStatus: 'failed', updated: [resumeQueue, pausedExecutions] },
285+
{ logStatus: 'running', updated: [resumeQueue, pausedExecutions, workflowExecutionLogs] },
286+
])(
287+
'leaves a $logStatus execution log as it is when a resume fails late',
288+
async ({ logStatus, updated }) => {
289+
queueTableRows(workflowExecutionLogs, [{ status: logStatus }])
290+
queueTableRows(pausedExecutions, [{ status: 'paused' }])
291+
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
292+
293+
await managerInternals.markResumeFailed(attemptArgs)
294+
295+
expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual(updated)
276296
}
277297
)
278298

@@ -313,17 +333,72 @@ describe('what a failed resume did to its paused execution', () => {
313333
}
314334
)
315335

336+
describe('when the resumed run pauses but its pause cannot be saved', () => {
337+
const spies: { mockRestore: () => void }[] = []
338+
339+
function pauseRun(options: { snapshotSeed?: unknown; persistError?: Error }) {
340+
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
341+
spies.push(
342+
vi.spyOn(managerInternals, 'runResumeExecution').mockResolvedValueOnce({
343+
success: true,
344+
status: 'paused',
345+
output: {},
346+
logs: [],
347+
pausePoints: [],
348+
snapshotSeed: options.snapshotSeed,
349+
metadata: { executionId: 'parent-execution-1', duration: 1, startTime: 'start' },
350+
}),
351+
vi.spyOn(managerInternals, 'markResumeFailed').mockResolvedValueOnce('execution_failed'),
352+
vi.spyOn(LoggingSession, 'markExecutionAsFailed').mockResolvedValueOnce(),
353+
vi.spyOn(PauseResumeManager, 'processQueuedResumes').mockResolvedValueOnce()
354+
)
355+
if (options.persistError) {
356+
spies.push(
357+
vi
358+
.spyOn(PauseResumeManager, 'persistPauseResult')
359+
.mockRejectedValueOnce(options.persistError)
360+
)
361+
}
362+
}
363+
364+
afterEach(() => {
365+
for (const spy of spies.splice(0)) spy.mockRestore()
366+
})
367+
368+
it('fails the attempt when the pause state cannot be persisted', async () => {
369+
pauseRun({ snapshotSeed: createSnapshotSeed(), persistError: new Error('lock timeout') })
370+
const { outcomes, args } = argsReportingOutcomes()
371+
372+
await expect(PauseResumeManager.startResumeExecution(args)).rejects.toMatchObject({
373+
message: 'Failed to persist pause state',
374+
cause: new Error('lock timeout'),
375+
})
376+
expect(outcomes).toEqual(['execution_failed'])
377+
})
378+
379+
it('fails the attempt when the paused run has no snapshot seed', async () => {
380+
pauseRun({})
381+
const { outcomes, args } = argsReportingOutcomes()
382+
383+
await expect(PauseResumeManager.startResumeExecution(args)).rejects.toThrow(
384+
'Missing snapshot seed for paused execution'
385+
)
386+
expect(outcomes).toEqual(['execution_failed'])
387+
})
388+
})
389+
316390
describe('when the resumed run fails', () => {
317391
const rawError = new Error('Block failed')
318392
const spies: { mockRestore: () => void }[] = []
319393

320-
function failRun(options: { executionFailed: boolean; queuedResumesError?: Error }) {
394+
function failRun(options: {
395+
outcome: FailedResumeOutcome | undefined
396+
queuedResumesError?: Error
397+
}) {
321398
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
322399
spies.push(
323400
vi.spyOn(managerInternals, 'runResumeExecution').mockRejectedValueOnce(rawError),
324-
vi
325-
.spyOn(managerInternals, 'markResumeFailed')
326-
.mockResolvedValueOnce(options.executionFailed),
401+
vi.spyOn(managerInternals, 'markResumeFailed').mockResolvedValueOnce(options.outcome),
327402
options.queuedResumesError
328403
? vi
329404
.spyOn(PauseResumeManager, 'processQueuedResumes')
@@ -337,12 +412,13 @@ describe('what a failed resume did to its paused execution', () => {
337412
})
338413

339414
it.each([
340-
{ executionFailed: true, reported: ['execution_failed'] },
341-
{ executionFailed: false, reported: [] },
415+
{ outcome: 'execution_failed' as const, reported: ['execution_failed'] },
416+
{ outcome: 'execution_completed' as const, reported: ['execution_completed'] },
417+
{ outcome: undefined, reported: [] },
342418
])(
343-
'reports $reported when it failed the execution: $executionFailed',
344-
async ({ executionFailed, reported }) => {
345-
failRun({ executionFailed })
419+
'reports $reported when the attempt left the execution $outcome',
420+
async ({ outcome, reported }) => {
421+
failRun({ outcome })
346422
const { outcomes, args } = argsReportingOutcomes()
347423

348424
await expect(PauseResumeManager.startResumeExecution(args)).rejects.toBe(rawError)
@@ -351,7 +427,7 @@ describe('what a failed resume did to its paused execution', () => {
351427
)
352428

353429
it('reports the outcome before draining queued resumes, which may throw', async () => {
354-
failRun({ executionFailed: true, queuedResumesError: new Error('queue drain failed') })
430+
failRun({ outcome: 'execution_failed', queuedResumesError: new Error('queue drain failed') })
355431
const { outcomes, args } = argsReportingOutcomes()
356432

357433
await expect(PauseResumeManager.startResumeExecution(args)).rejects.toThrow(
@@ -361,7 +437,7 @@ describe('what a failed resume did to its paused execution', () => {
361437
})
362438

363439
it('rethrows the run failure when the outcome handler fails', async () => {
364-
failRun({ executionFailed: true })
440+
failRun({ outcome: 'execution_failed' })
365441
const { args } = argsReportingOutcomes(async () => {
366442
throw new Error('Database unavailable')
367443
})

0 commit comments

Comments
 (0)