Skip to content

Commit 78554f7

Browse files
committed
fix(workflows): preserve durable execution across repeated human pauses
1 parent 15fc6ab commit 78554f7

2 files changed

Lines changed: 129 additions & 7 deletions

File tree

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

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core
1414
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
1515
import { terminalExecutionLogFields } from '@/lib/logs/execution/cancellation'
1616

17-
const { mockReleaseExecutionSlot, mockReplaceLargeValueReferenceKeysWithClient } = vi.hoisted(
18-
() => ({
19-
mockReleaseExecutionSlot: vi.fn(),
20-
mockReplaceLargeValueReferenceKeysWithClient: vi.fn(),
21-
})
22-
)
17+
const {
18+
mockReleaseExecutionSlot,
19+
mockReplaceLargeValueReferenceKeysWithClient,
20+
mockPreprocessExecution,
21+
} = vi.hoisted(() => ({
22+
mockReleaseExecutionSlot: vi.fn(),
23+
mockPreprocessExecution: vi.fn(),
24+
mockReplaceLargeValueReferenceKeysWithClient: vi.fn(),
25+
}))
26+
27+
vi.mock('@/lib/execution/preprocessing', () => ({ preprocessExecution: mockPreprocessExecution }))
2328

2429
vi.mock('@/lib/billing/calculations/usage-reservation', () => ({
2530
releaseExecutionSlot: mockReleaseExecutionSlot,
@@ -1996,3 +2001,118 @@ describe('PauseResumeManager.enqueueOrStartResume admission refusals', () => {
19962001
await expect(enqueue()).rejects.toMatchObject({ retryable: false })
19972002
})
19982003
})
2004+
2005+
describe('repeated human review pauses', () => {
2006+
beforeEach(() => {
2007+
vi.clearAllMocks()
2008+
resetDbChainMock()
2009+
})
2010+
2011+
it('keeps the next pause snapshot attached to the log that admission claimed', async () => {
2012+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'log-1', deploymentVersionId: null }])
2013+
const stopAfterSnapshot = new Error('stop after snapshot construction')
2014+
mockPreprocessExecution.mockRejectedValueOnce(stopAfterSnapshot)
2015+
const seed = createSnapshotSeed()
2016+
const snapshot = JSON.parse(seed.snapshot)
2017+
snapshot.metadata = {
2018+
...snapshot.metadata,
2019+
workflowId: 'workflow-1',
2020+
executionId: 'durable-run',
2021+
useDraftState: true,
2022+
triggerType: 'manual',
2023+
}
2024+
snapshot.workflow = { blocks: [], connections: [] }
2025+
snapshot.state = { ...createExecutionState(), dagIncomingEdges: {} }
2026+
const runResumeExecution = Reflect.get(PauseResumeManager, 'runResumeExecution') as (
2027+
args: Record<string, unknown>
2028+
) => Promise<unknown>
2029+
2030+
await expect(
2031+
runResumeExecution({
2032+
reservationId: 'reservation-1',
2033+
resumeExecutionId: 'attempt-1',
2034+
pausedExecution: {
2035+
id: 'pause-1',
2036+
workflowId: 'workflow-1',
2037+
executionId: 'durable-run',
2038+
executionSnapshot: { ...seed, snapshot: JSON.stringify(snapshot) },
2039+
pausePoints: { hitl_loop0: { blockId: 'hitl', pauseKind: 'human' } },
2040+
},
2041+
contextId: 'hitl_loop0',
2042+
resumeInput: { reply: 'partial answer' },
2043+
userId: 'user-1',
2044+
})
2045+
).rejects.toBe(stopAfterSnapshot)
2046+
2047+
expect(humanInTheLoopLogger.info).toHaveBeenCalledWith(
2048+
'Created resume snapshot',
2049+
expect.objectContaining({ metadata: expect.objectContaining({ executionId: 'durable-run' }) })
2050+
)
2051+
expect(
2052+
dbChainMockFns.where.mock.calls.some(([condition]) =>
2053+
flattenMockConditions(condition).some(
2054+
(part) =>
2055+
part.type === 'eq' &&
2056+
part.left === 'workflowExecutionLogs.executionId' &&
2057+
part.right === 'durable-run'
2058+
)
2059+
)
2060+
).toBe(true)
2061+
})
2062+
2063+
it('settles the answered context when the same run pauses at its next question', async () => {
2064+
const runSpy = vi
2065+
.spyOn(PauseResumeManager as unknown as PauseResumeManagerInternals, 'runResumeExecution')
2066+
.mockResolvedValueOnce({
2067+
status: 'paused',
2068+
success: true,
2069+
metadata: { executionId: 'durable-run' },
2070+
snapshotSeed: createSnapshotSeed(),
2071+
pausePoints: [{ contextId: 'hitl_loop1', blockId: 'hitl' }],
2072+
})
2073+
const persistSpy = vi
2074+
.spyOn(PauseResumeManager, 'persistPauseResult')
2075+
.mockResolvedValueOnce(undefined)
2076+
const completeSpy = vi
2077+
.spyOn(
2078+
PauseResumeManager as unknown as {
2079+
markResumeCompleted: (...args: unknown[]) => Promise<void>
2080+
},
2081+
'markResumeCompleted'
2082+
)
2083+
.mockResolvedValueOnce()
2084+
const processSpy = vi.spyOn(PauseResumeManager, 'processQueuedResumes').mockResolvedValueOnce()
2085+
type Args = Parameters<typeof PauseResumeManager.startResumeExecution>[0]
2086+
try {
2087+
await PauseResumeManager.startResumeExecution({
2088+
resumeEntryId: 'entry-1',
2089+
resumeExecutionId: 'attempt-1',
2090+
contextId: 'hitl_loop0',
2091+
resumeInput: { reply: 'partial answer' },
2092+
userId: 'user-1',
2093+
pausedExecution: {
2094+
id: 'pause-1',
2095+
executionId: 'durable-run',
2096+
workflowId: 'workflow-1',
2097+
pausePoints: { hitl_loop0: { contextId: 'hitl_loop0', blockId: 'hitl' } },
2098+
executionSnapshot: createSnapshotSeed(),
2099+
metadata: {},
2100+
} as Args['pausedExecution'],
2101+
})
2102+
expect(persistSpy).toHaveBeenCalledWith(
2103+
expect.objectContaining({ executionId: 'durable-run' })
2104+
)
2105+
expect(completeSpy).toHaveBeenCalledWith(
2106+
expect.objectContaining({
2107+
parentExecutionId: 'durable-run',
2108+
contextId: 'hitl_loop0',
2109+
})
2110+
)
2111+
} finally {
2112+
runSpy.mockRestore()
2113+
persistSpy.mockRestore()
2114+
completeSpy.mockRestore()
2115+
processSpy.mockRestore()
2116+
}
2117+
})
2118+
})

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,7 @@ export class PauseResumeManager {
973973
resumeEntryId,
974974
pausedExecutionId: pausedExecution.id,
975975
parentExecutionId: pausedExecution.executionId,
976+
contextId,
976977
})
977978
}
978979

@@ -1424,9 +1425,10 @@ export class PauseResumeManager {
14241425
})
14251426
}
14261427

1428+
/** Resume attempts have separate stream IDs; new pauses must retain the durable run ID. */
14271429
const metadata = {
14281430
...baseSnapshot.metadata,
1429-
executionId: resumeExecutionId,
1431+
executionId: parentExecutionId,
14301432
requestId: baseSnapshot.metadata.requestId,
14311433
startTime: new Date().toISOString(),
14321434
userId: effectiveUserId,

0 commit comments

Comments
 (0)