@@ -112,6 +112,8 @@ function createMockEdgeManager(
112112 getDeactivatedEdges : vi . fn ( ( ) => [ ] ) ,
113113 getNodesWithActivatedEdge : vi . fn ( ( ) => [ ] ) ,
114114 markNodeWithActivatedEdge : vi . fn ( ) ,
115+ deactivateResumedEdge : vi . fn ( ) ,
116+ hasActivatedEdge : vi . fn ( ( ) => false ) ,
115117 } as unknown as MockEdgeManager
116118}
117119
@@ -231,6 +233,136 @@ describe('ExecutionEngine', () => {
231233 expect ( nodeOrchestrator . executeNode ) . not . toHaveBeenCalled ( )
232234 } )
233235
236+ it ( 'deactivates a resumed pause block error edge instead of firing it' , async ( ) => {
237+ // Pause block has two outgoing edges: a `source` continuation edge and an
238+ // `error` edge to an error-notifier. On a successful resume the error edge
239+ // must be deactivated (never marked/queued); only the continuation fires.
240+ const pauseNode = createMockNode ( 'pause-block' , 'human_in_the_loop' )
241+ pauseNode . outgoingEdges . set ( 'pause-block→next-source' , {
242+ target : 'next' ,
243+ sourceHandle : EDGE . SOURCE ,
244+ } )
245+ pauseNode . outgoingEdges . set ( 'pause-block→notify-error' , {
246+ target : 'error-notify' ,
247+ sourceHandle : EDGE . ERROR ,
248+ } )
249+
250+ const nextNode = createMockNode ( 'next' , 'function' )
251+ nextNode . incomingEdges . add ( 'pause-block' )
252+
253+ const errorNotifyNode = createMockNode ( 'error-notify' , 'gmail' )
254+ errorNotifyNode . incomingEdges . add ( 'pause-block' )
255+
256+ const dag = createMockDAG ( [ pauseNode , nextNode , errorNotifyNode ] )
257+ const context = createMockContext ( {
258+ metadata : {
259+ executionId : 'test-execution' ,
260+ startTime : new Date ( ) . toISOString ( ) ,
261+ pendingBlocks : [ ] ,
262+ // remainingEdges omit sourceHandle (as persisted snapshots do), forcing
263+ // the engine to resolve the handle from the live DAG.
264+ remainingEdges : [
265+ { source : 'pause-block' , target : 'next' } ,
266+ { source : 'pause-block' , target : 'error-notify' } ,
267+ ] ,
268+ } as any ,
269+ } )
270+ const edgeManager = createMockEdgeManager ( ( ) => [ ] )
271+ const nodeOrchestrator = createMockNodeOrchestrator ( )
272+
273+ const engine = new ExecutionEngine ( context , dag , edgeManager , nodeOrchestrator )
274+ await engine . run ( )
275+
276+ // Continuation edge fires; error edge is deactivated, not activated/queued.
277+ expect ( edgeManager . markNodeWithActivatedEdge ) . toHaveBeenCalledWith ( 'next' )
278+ expect ( edgeManager . markNodeWithActivatedEdge ) . not . toHaveBeenCalledWith ( 'error-notify' )
279+ expect ( edgeManager . deactivateResumedEdge ) . toHaveBeenCalledWith (
280+ 'pause-block' ,
281+ 'error-notify' ,
282+ EDGE . ERROR
283+ )
284+ // A pure error-handler target (never activated) must not be executed.
285+ expect ( nodeOrchestrator . executeNode ) . not . toHaveBeenCalledWith ( context , 'error-notify' )
286+ } )
287+
288+ it ( 're-queues a convergence target when a resumed pause error edge is pruned' , async ( ) => {
289+ // The join is fed by a succeeding block's `source` edge (activated in
290+ // phase 1) AND the pause block's `error` edge. On resume the error edge is
291+ // pruned, but the join must still run because it already had a genuine
292+ // activation — otherwise it would be silently stranded.
293+ const pauseNode = createMockNode ( 'pause-block' , 'human_in_the_loop' )
294+ pauseNode . outgoingEdges . set ( 'pause-block→join-error' , {
295+ target : 'join' ,
296+ sourceHandle : EDGE . ERROR ,
297+ } )
298+ const joinNode = createMockNode ( 'join' , 'function' )
299+ joinNode . incomingEdges . add ( 'pause-block' )
300+
301+ const dag = createMockDAG ( [ pauseNode , joinNode ] )
302+ const context = createMockContext ( {
303+ metadata : {
304+ executionId : 'test-execution' ,
305+ startTime : new Date ( ) . toISOString ( ) ,
306+ pendingBlocks : [ ] ,
307+ remainingEdges : [ { source : 'pause-block' , target : 'join' } ] ,
308+ } as any ,
309+ } )
310+ const edgeManager = createMockEdgeManager ( ( ) => [ ] )
311+ // Join already received a genuine activation in phase 1 and is now ready.
312+ vi . mocked ( edgeManager . hasActivatedEdge ) . mockReturnValue ( true )
313+ vi . mocked ( edgeManager . isNodeReady ) . mockReturnValue ( true )
314+ const nodeOrchestrator = createMockNodeOrchestrator ( )
315+
316+ const engine = new ExecutionEngine ( context , dag , edgeManager , nodeOrchestrator )
317+ await engine . run ( )
318+
319+ expect ( edgeManager . deactivateResumedEdge ) . toHaveBeenCalledWith (
320+ 'pause-block' ,
321+ 'join' ,
322+ EDGE . ERROR
323+ )
324+ // Pruning is via deactivation, never force-activation...
325+ expect ( edgeManager . markNodeWithActivatedEdge ) . not . toHaveBeenCalledWith ( 'join' )
326+ // ...but the already-activated convergence node still runs.
327+ expect ( nodeOrchestrator . executeNode ) . toHaveBeenCalledWith ( context , 'join' )
328+ } )
329+
330+ it ( 'prefers the continuation handle when a pause block also errors into the same target' , async ( ) => {
331+ // Pause block wires BOTH source and error into the same target. The error
332+ // edge is registered first, but on a successful resume the continuation
333+ // (source) handle must win, so the target is activated, not pruned.
334+ const pauseNode = createMockNode ( 'pause-block' , 'human_in_the_loop' )
335+ pauseNode . outgoingEdges . set ( 'pause-block→both-error' , {
336+ target : 'both' ,
337+ sourceHandle : EDGE . ERROR ,
338+ } )
339+ pauseNode . outgoingEdges . set ( 'pause-block→both-source' , {
340+ target : 'both' ,
341+ sourceHandle : EDGE . SOURCE ,
342+ } )
343+ const bothNode = createMockNode ( 'both' , 'function' )
344+ bothNode . incomingEdges . add ( 'pause-block' )
345+
346+ const dag = createMockDAG ( [ pauseNode , bothNode ] )
347+ const context = createMockContext ( {
348+ metadata : {
349+ executionId : 'test-execution' ,
350+ startTime : new Date ( ) . toISOString ( ) ,
351+ pendingBlocks : [ ] ,
352+ remainingEdges : [ { source : 'pause-block' , target : 'both' } ] ,
353+ } as any ,
354+ } )
355+ const edgeManager = createMockEdgeManager ( ( ) => [ ] )
356+ const nodeOrchestrator = createMockNodeOrchestrator ( )
357+
358+ const engine = new ExecutionEngine ( context , dag , edgeManager , nodeOrchestrator )
359+ await engine . run ( )
360+
361+ expect ( edgeManager . markNodeWithActivatedEdge ) . toHaveBeenCalledWith ( 'both' )
362+ expect ( edgeManager . deactivateResumedEdge ) . not . toHaveBeenCalled ( )
363+ expect ( nodeOrchestrator . executeNode ) . toHaveBeenCalledWith ( context , 'both' )
364+ } )
365+
234366 it ( 'should execute all nodes in a multi-node workflow' , async ( ) => {
235367 const nodes = [
236368 createMockNode ( 'start' , 'starter' ) ,
0 commit comments