Skip to content

Commit e19a8ab

Browse files
committed
fix(tables): mirror what a failed resume did instead of inferring it from the error
An admission refusal can also mean the execution already finished, so treating every refusal as a kept pause wrote paused over a completed cell. markResumeAttemptFailed and markResumeFailed now return what their transaction actually did (pause still resumable / execution failed), the manager records that outcome on the rethrown error, and the resume job writes paused, error, or nothing.
1 parent 9eed4a3 commit e19a8ab

5 files changed

Lines changed: 139 additions & 69 deletions

File tree

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import { classifyWorkflowCellTerminalResult } from '@/lib/table/workflow-cell-re
2222
import type { CellResumeContext } from '@/lib/table/workflow-columns'
2323
import {
2424
createResumeAttemptTimeoutController,
25+
getFailedResumeOutcome,
2526
PauseResumeManager,
26-
wasPausedExecutionRetained,
2727
} from '@/lib/workflows/executor/human-in-the-loop-manager'
2828
import { RESUME_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits'
2929
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
@@ -364,12 +364,15 @@ 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 the execution instead: a pause the resume kept
368-
* resumable goes back to paused, and any other failure ended the run.
367+
* partial `running` state. Mirror what the failed attempt did to the execution:
368+
* a pause that stayed resumable goes back to paused, a failed execution fails
369+
* the cell, and an attempt that changed nothing leaves the cell alone.
369370
*/
370371
async function writeFailedResumeCellTerminal(writers: CellWriters, error: unknown): Promise<void> {
372+
const outcome = getFailedResumeOutcome(error)
373+
if (!outcome) return
371374
try {
372-
if (wasPausedExecutionRetained(error)) {
375+
if (outcome === 'pause_retained') {
373376
await writers.writeCellTerminal('paused', null)
374377
} else {
375378
await writers.writeCellTerminal('error', getErrorMessage(error, 'Resume execution failed'))

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const mocks = {
5757
...hoisted,
5858
getPausedExecutionById: humanInTheLoopManagerMockFns.mockGetPausedExecutionById,
5959
startResumeExecution: humanInTheLoopManagerMockFns.mockStartResumeExecution,
60-
wasPausedExecutionRetained: humanInTheLoopManagerMockFns.mockWasPausedExecutionRetained,
60+
getFailedResumeOutcome: humanInTheLoopManagerMockFns.mockGetFailedResumeOutcome,
6161
createResumeAttemptTimeoutController:
6262
humanInTheLoopManagerMockFns.mockCreateResumeAttemptTimeoutController,
6363
findCellContextByExecutionId: tableWorkflowColumnsMockFns.mockFindCellContextByExecutionId,
@@ -226,9 +226,10 @@ describe('resuming a paused table cell', () => {
226226
return payload?.executionState
227227
}
228228

229-
it('marks the cell failed when the resumed run itself failed', async () => {
229+
it('marks the cell failed when the resume failed the execution', async () => {
230230
const runFailure = new Error('writeLedger: Unique constraint violation')
231231
mocks.startResumeExecution.mockRejectedValueOnce(runFailure)
232+
mocks.getFailedResumeOutcome.mockReturnValueOnce('execution_failed')
232233

233234
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(runFailure)
234235

@@ -242,7 +243,7 @@ describe('resuming a paused table cell', () => {
242243
it('puts the cell back to paused when the pause stayed resumable', async () => {
243244
const admissionRefusal = new Error('Execution can no longer be resumed')
244245
mocks.startResumeExecution.mockRejectedValueOnce(admissionRefusal)
245-
mocks.wasPausedExecutionRetained.mockReturnValueOnce(true)
246+
mocks.getFailedResumeOutcome.mockReturnValueOnce('pause_retained')
246247

247248
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(admissionRefusal)
248249

@@ -253,9 +254,20 @@ describe('resuming a paused table cell', () => {
253254
})
254255
}, 20_000)
255256

257+
it('leaves the cell alone when the failed attempt changed nothing', async () => {
258+
const staleRefusal = new Error('Execution can no longer be resumed')
259+
mocks.startResumeExecution.mockRejectedValueOnce(staleRefusal)
260+
mocks.getFailedResumeOutcome.mockReturnValueOnce(undefined)
261+
262+
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(staleRefusal)
263+
264+
expect(lastCellExecutionState()).toBeUndefined()
265+
}, 20_000)
266+
256267
it('still reports the resume failure when the cell write also fails', async () => {
257268
const runFailure = new Error('Block failed')
258269
mocks.startResumeExecution.mockRejectedValueOnce(runFailure)
270+
mocks.getFailedResumeOutcome.mockReturnValueOnce('execution_failed')
259271
mocks.writeWorkflowGroupState.mockRejectedValueOnce(new Error('Database unavailable'))
260272

261273
await expect(executeResumeJob(PAYLOAD)).rejects.toBe(runFailure)

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

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ vi.mock('@/lib/execution/payloads/large-value-metadata', () => largeValueMetadat
6161

6262
import {
6363
createResumeAttemptTimeoutController,
64+
getFailedResumeOutcome,
6465
PauseResumeManager,
6566
requireResumeDeploymentVersion,
6667
updateResumeOutputInAggregationBuffers,
67-
wasPausedExecutionRetained,
6868
} from '@/lib/workflows/executor/human-in-the-loop-manager'
6969
import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata'
7070
import { AUTOMATIC_RESUME_WAITING_REASON_MAX_LENGTH } from '@/lib/workflows/executor/resume-policy'
@@ -85,7 +85,7 @@ if (!humanInTheLoopLogger) {
8585
}
8686

8787
interface PauseResumeManagerInternals {
88-
markResumeFailed: (...args: unknown[]) => Promise<void>
88+
markResumeFailed: (...args: unknown[]) => Promise<boolean>
8989
runResumeExecution: (...args: unknown[]) => Promise<unknown>
9090
}
9191

@@ -211,7 +211,7 @@ describe('queued resume attempt deadlines', () => {
211211
})
212212
})
213213

214-
describe('which failed resumes keep the pause resumable', () => {
214+
describe('what a failed resume did to its paused execution', () => {
215215
type StartResumeArgs = Parameters<typeof PauseResumeManager.startResumeExecution>[0]
216216
const pausedExecution = {
217217
id: 'paused-execution-1',
@@ -231,50 +231,87 @@ describe('which failed resumes keep the pause resumable', () => {
231231
resumeInput: { approved: true },
232232
userId: 'user-1',
233233
}
234+
const attemptArgs = {
235+
resumeEntryId: 'resume-entry-1',
236+
pausedExecutionId: 'paused-execution-1',
237+
parentExecutionId: 'parent-execution-1',
238+
contextId: 'context-1',
239+
failureReason: 'Execution can no longer be resumed',
240+
preserveForRetry: true,
241+
}
234242

235243
beforeEach(() => {
236244
resetDbChainMock()
237245
})
238246

239-
it('keeps the pause resumable when the paused log can no longer be claimed', async () => {
240-
const markResumeAttemptFailedSpy = vi
241-
.spyOn(PauseResumeManager, 'markResumeAttemptFailed')
242-
.mockResolvedValueOnce()
247+
it('reports the pause still resumable when a refused attempt leaves it paused', async () => {
248+
queueTableRows(workflowExecutionLogs, [{ status: 'paused' }])
249+
queueTableRows(pausedExecutions, [{ automaticResumeRetryCount: 0, status: 'paused' }])
243250

244-
try {
245-
const thrown = await PauseResumeManager.startResumeExecution(resumeArgs).catch(
246-
(error: unknown) => error
247-
)
251+
await expect(PauseResumeManager.markResumeAttemptFailed(attemptArgs)).resolves.toBe(true)
252+
})
248253

249-
expect(thrown).toMatchObject({ name: 'ResumeAdmissionError' })
250-
expect(wasPausedExecutionRetained(thrown)).toBe(true)
251-
} finally {
252-
markResumeAttemptFailedSpy.mockRestore()
254+
it.each(['completed', 'failed', 'cancelled'])(
255+
'reports the pause not resumable when the execution is already %s',
256+
async (logStatus) => {
257+
queueTableRows(workflowExecutionLogs, [{ status: logStatus }])
258+
queueTableRows(pausedExecutions, [{ automaticResumeRetryCount: 0, status: 'paused' }])
259+
260+
await expect(PauseResumeManager.markResumeAttemptFailed(attemptArgs)).resolves.toBe(false)
253261
}
254-
})
262+
)
255263

256-
it('does not keep the pause when the resumed run itself failed', async () => {
257-
const rawError = new Error('Block failed')
258-
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
259-
const runResumeExecutionSpy = vi
260-
.spyOn(managerInternals, 'runResumeExecution')
261-
.mockRejectedValueOnce(rawError)
262-
const markResumeFailedSpy = vi
263-
.spyOn(managerInternals, 'markResumeFailed')
264-
.mockResolvedValueOnce()
265-
const processQueuedResumesSpy = vi
266-
.spyOn(PauseResumeManager, 'processQueuedResumes')
267-
.mockResolvedValueOnce()
264+
it.each([
265+
{ stillResumable: true, outcome: 'pause_retained' },
266+
{ stillResumable: false, outcome: undefined },
267+
])(
268+
'records a refused attempt as $outcome when the pause is resumable: $stillResumable',
269+
async ({ stillResumable, outcome }) => {
270+
const markResumeAttemptFailedSpy = vi
271+
.spyOn(PauseResumeManager, 'markResumeAttemptFailed')
272+
.mockResolvedValueOnce(stillResumable)
273+
274+
try {
275+
const thrown = await PauseResumeManager.startResumeExecution(resumeArgs).catch(
276+
(error: unknown) => error
277+
)
268278

269-
try {
270-
await expect(PauseResumeManager.startResumeExecution(resumeArgs)).rejects.toBe(rawError)
271-
expect(wasPausedExecutionRetained(rawError)).toBe(false)
272-
} finally {
273-
runResumeExecutionSpy.mockRestore()
274-
markResumeFailedSpy.mockRestore()
275-
processQueuedResumesSpy.mockRestore()
279+
expect(thrown).toMatchObject({ name: 'ResumeAdmissionError' })
280+
expect(getFailedResumeOutcome(thrown)).toBe(outcome)
281+
} finally {
282+
markResumeAttemptFailedSpy.mockRestore()
283+
}
276284
}
277-
})
285+
)
286+
287+
it.each([
288+
{ executionFailed: true, outcome: 'execution_failed' },
289+
{ executionFailed: false, outcome: undefined },
290+
])(
291+
'records a failed run as $outcome when it failed the execution: $executionFailed',
292+
async ({ executionFailed, outcome }) => {
293+
const rawError = new Error('Block failed')
294+
const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals
295+
const runResumeExecutionSpy = vi
296+
.spyOn(managerInternals, 'runResumeExecution')
297+
.mockRejectedValueOnce(rawError)
298+
const markResumeFailedSpy = vi
299+
.spyOn(managerInternals, 'markResumeFailed')
300+
.mockResolvedValueOnce(executionFailed)
301+
const processQueuedResumesSpy = vi
302+
.spyOn(PauseResumeManager, 'processQueuedResumes')
303+
.mockResolvedValueOnce()
304+
305+
try {
306+
await expect(PauseResumeManager.startResumeExecution(resumeArgs)).rejects.toBe(rawError)
307+
expect(getFailedResumeOutcome(rawError)).toBe(outcome)
308+
} finally {
309+
runResumeExecutionSpy.mockRestore()
310+
markResumeFailedSpy.mockRestore()
311+
processQueuedResumesSpy.mockRestore()
312+
}
313+
}
314+
)
278315
})
279316

280317
describe('resume failure diagnostic projection', () => {

0 commit comments

Comments
 (0)