Skip to content

Commit 6be9ba0

Browse files
committed
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.
1 parent b6501d0 commit 6be9ba0

4 files changed

Lines changed: 64 additions & 42 deletions

File tree

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -364,19 +364,26 @@ async function buildResumeCellWriters(
364364
/**
365365
* A resume that throws never reaches the terminal write in
366366
* {@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.
367+
* partial `running` state. Mirror what the failed attempt left the execution
368+
* as: a pause that stayed resumable goes back to paused, a failed execution
369+
* fails the cell, and a run that completed before a later step threw
370+
* completes it.
370371
*/
371372
async function writeFailedResumeCellTerminal(
372373
writers: CellWriters,
373374
outcome: FailedResumeOutcome,
374375
error: unknown
375376
): 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'))
377+
switch (outcome) {
378+
case 'pause_retained':
379+
await writers.writeCellTerminal('paused', null)
380+
return
381+
case 'execution_completed':
382+
await writers.writeCellTerminal('completed', null)
383+
return
384+
case 'execution_failed':
385+
await writers.writeCellTerminal('error', getErrorMessage(error, 'Resume execution failed'))
386+
return
380387
}
381388
}
382389

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,19 @@ describe('resuming a paused table cell', () => {
253253
})
254254
}, 20_000)
255255

256+
it('marks the cell completed when the run completed before a later step failed', async () => {
257+
const bookkeepingFailure = new Error('Database unavailable')
258+
failResume('execution_completed', bookkeepingFailure)
259+
260+
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(bookkeepingFailure)
261+
262+
expect(lastCellExecutionState()).toMatchObject({
263+
status: 'completed',
264+
executionId: 'parent-execution-1',
265+
error: null,
266+
})
267+
}, 20_000)
268+
256269
it('puts the cell back to paused when the pause stayed resumable', async () => {
257270
const admissionRefusal = new Error('Execution can no longer be resumed')
258271
failResume('pause_retained', admissionRefusal)

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

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ if (!humanInTheLoopLogger) {
8686
}
8787

8888
interface PauseResumeManagerInternals {
89-
markResumeFailed: (...args: unknown[]) => Promise<boolean>
89+
markResumeFailed: (...args: unknown[]) => Promise<FailedResumeOutcome | undefined>
9090
runResumeExecution: (...args: unknown[]) => Promise<unknown>
9191
}
9292

@@ -263,24 +263,24 @@ describe('what a failed resume did to its paused execution', () => {
263263
)
264264

265265
it.each([
266-
{ logStatus: 'running', pauseStatus: 'paused', executionFailed: true },
267-
{ logStatus: 'cancelled', pauseStatus: 'paused', executionFailed: false },
268-
{ logStatus: 'running', pauseStatus: 'cancelling', executionFailed: false },
269-
{ logStatus: 'failed', pauseStatus: 'paused', executionFailed: true },
270-
{ logStatus: 'completed', pauseStatus: 'paused', 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' },
271271
])(
272-
'reports the execution failed: $executionFailed for a $logStatus log and $pauseStatus pause',
273-
async ({ logStatus, pauseStatus, executionFailed }) => {
272+
'reports $outcome for a $logStatus log and $pauseStatus pause',
273+
async ({ logStatus, pauseStatus, outcome }) => {
274274
queueTableRows(workflowExecutionLogs, [{ status: logStatus }])
275275
queueTableRows(pausedExecutions, [{ status: pauseStatus }])
276276
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
277277

278-
await expect(managerInternals.markResumeFailed(attemptArgs)).resolves.toBe(executionFailed)
278+
await expect(managerInternals.markResumeFailed(attemptArgs)).resolves.toBe(outcome)
279279
}
280280
)
281281

282282
it.each([
283-
{ logStatus: 'completed', updated: [resumeQueue] },
283+
{ logStatus: 'completed', updated: [resumeQueue, pausedExecutions] },
284284
{ logStatus: 'failed', updated: [resumeQueue, pausedExecutions] },
285285
{ logStatus: 'running', updated: [resumeQueue, pausedExecutions, workflowExecutionLogs] },
286286
])(
@@ -348,7 +348,7 @@ describe('what a failed resume did to its paused execution', () => {
348348
snapshotSeed: options.snapshotSeed,
349349
metadata: { executionId: 'parent-execution-1', duration: 1, startTime: 'start' },
350350
}),
351-
vi.spyOn(managerInternals, 'markResumeFailed').mockResolvedValueOnce(true),
351+
vi.spyOn(managerInternals, 'markResumeFailed').mockResolvedValueOnce('execution_failed'),
352352
vi.spyOn(LoggingSession, 'markExecutionAsFailed').mockResolvedValueOnce(),
353353
vi.spyOn(PauseResumeManager, 'processQueuedResumes').mockResolvedValueOnce()
354354
)
@@ -390,13 +390,14 @@ describe('what a failed resume did to its paused execution', () => {
390390
const rawError = new Error('Block failed')
391391
const spies: { mockRestore: () => void }[] = []
392392

393-
function failRun(options: { executionFailed: boolean; queuedResumesError?: Error }) {
393+
function failRun(options: {
394+
outcome: FailedResumeOutcome | undefined
395+
queuedResumesError?: Error
396+
}) {
394397
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
395398
spies.push(
396399
vi.spyOn(managerInternals, 'runResumeExecution').mockRejectedValueOnce(rawError),
397-
vi
398-
.spyOn(managerInternals, 'markResumeFailed')
399-
.mockResolvedValueOnce(options.executionFailed),
400+
vi.spyOn(managerInternals, 'markResumeFailed').mockResolvedValueOnce(options.outcome),
400401
options.queuedResumesError
401402
? vi
402403
.spyOn(PauseResumeManager, 'processQueuedResumes')
@@ -410,12 +411,13 @@ describe('what a failed resume did to its paused execution', () => {
410411
})
411412

412413
it.each([
413-
{ executionFailed: true, reported: ['execution_failed'] },
414-
{ executionFailed: false, reported: [] },
414+
{ outcome: 'execution_failed' as const, reported: ['execution_failed'] },
415+
{ outcome: 'execution_completed' as const, reported: ['execution_completed'] },
416+
{ outcome: undefined, reported: [] },
415417
])(
416-
'reports $reported when it failed the execution: $executionFailed',
417-
async ({ executionFailed, reported }) => {
418-
failRun({ executionFailed })
418+
'reports $reported when the attempt left the execution $outcome',
419+
async ({ outcome, reported }) => {
420+
failRun({ outcome })
419421
const { outcomes, args } = argsReportingOutcomes()
420422

421423
await expect(PauseResumeManager.startResumeExecution(args)).rejects.toBe(rawError)
@@ -424,7 +426,7 @@ describe('what a failed resume did to its paused execution', () => {
424426
)
425427

426428
it('reports the outcome before draining queued resumes, which may throw', async () => {
427-
failRun({ executionFailed: true, queuedResumesError: new Error('queue drain failed') })
429+
failRun({ outcome: 'execution_failed', queuedResumesError: new Error('queue drain failed') })
428430
const { outcomes, args } = argsReportingOutcomes()
429431

430432
await expect(PauseResumeManager.startResumeExecution(args)).rejects.toThrow(
@@ -434,7 +436,7 @@ describe('what a failed resume did to its paused execution', () => {
434436
})
435437

436438
it('rethrows the run failure when the outcome handler fails', async () => {
437-
failRun({ executionFailed: true })
439+
failRun({ outcome: 'execution_failed' })
438440
const { args } = argsReportingOutcomes(async () => {
439441
throw new Error('Database unavailable')
440442
})

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,12 @@ class ResumeAdmissionError extends Error {
149149
}
150150

151151
/**
152-
* What a failed resume attempt did to its paused execution, as reported by the
153-
* transaction that settled the attempt: the pause stayed resumable, or the
154-
* resumed run failed the execution.
152+
* What a failed resume attempt left its paused execution as, read from the
153+
* transaction that settled the attempt: the pause stayed resumable, the
154+
* resumed run failed the execution, or the run completed before a later step
155+
* of the attempt threw.
155156
*/
156-
export type FailedResumeOutcome = 'pause_retained' | 'execution_failed'
157+
export type FailedResumeOutcome = 'pause_retained' | 'execution_failed' | 'execution_completed'
157158

158159
/** Matches the paused execution mode to the deployment recorded on its durable root log. */
159160
export function requireResumeDeploymentVersion(
@@ -1032,14 +1033,13 @@ export class PauseResumeManager {
10321033
})
10331034
if (pauseResumable) outcome = 'pause_retained'
10341035
} else {
1035-
const executionFailed = await PauseResumeManager.markResumeFailed({
1036+
outcome = await PauseResumeManager.markResumeFailed({
10361037
resumeEntryId,
10371038
pausedExecutionId: pausedExecution.id,
10381039
parentExecutionId: pausedExecution.executionId,
10391040
contextId,
10401041
failureReason: message,
10411042
})
1042-
if (executionFailed) outcome = 'execution_failed'
10431043
}
10441044
if (outcome && onAttemptFailed) {
10451045
await onAttemptFailed(outcome, error).catch((hookError: unknown) => {
@@ -2228,7 +2228,7 @@ export class PauseResumeManager {
22282228
parentExecutionId: string
22292229
contextId: string
22302230
failureReason: string
2231-
}): Promise<boolean> {
2231+
}): Promise<'execution_failed' | 'execution_completed' | undefined> {
22322232
const now = new Date()
22332233

22342234
return execDb.transaction(async (tx) => {
@@ -2260,12 +2260,9 @@ export class PauseResumeManager {
22602260
.set({ status: 'cancelled', updatedAt: now, nextResumeAt: null })
22612261
.where(eq(pausedExecutions.id, args.pausedExecutionId))
22622262
}
2263-
return false
2263+
return undefined
22642264
}
22652265

2266-
/** The run completed before a later step threw; its outcome stands. */
2267-
if (executionLog?.status === 'completed') return false
2268-
22692266
await tx
22702267
.update(pausedExecutions)
22712268
.set({
@@ -2274,7 +2271,10 @@ export class PauseResumeManager {
22742271
})
22752272
.where(eq(pausedExecutions.id, args.pausedExecutionId))
22762273

2277-
if (pausedExecution?.status === 'cancelling') return false
2274+
if (pausedExecution?.status === 'cancelling') return undefined
2275+
2276+
/** The run completed before a later step of the attempt threw; its outcome stands. */
2277+
if (executionLog?.status === 'completed') return 'execution_completed'
22782278

22792279
if (executionLog?.status !== 'failed') {
22802280
await tx
@@ -2288,7 +2288,7 @@ export class PauseResumeManager {
22882288
)
22892289
}
22902290

2291-
return true
2291+
return 'execution_failed'
22922292
})
22932293
}
22942294

0 commit comments

Comments
 (0)