@@ -14,12 +14,17 @@ import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core
1414import { abortManualExecution } from '@/lib/execution/manual-cancellation'
1515import { 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
2429vi . 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+ } )
0 commit comments