@@ -600,6 +600,171 @@ describe('EdgeManager', () => {
600600 } )
601601 expect ( readyNodes ) . toContain ( function1Id )
602602 } )
603+
604+ /**
605+ * Regression for the substring-match bug in clearDeactivatedEdgesForNodes.
606+ *
607+ * Reproduces the real workflow pattern where an empty upstream loop (e.g. KG) cascade
608+ * deactivates its `loop_exit` edge into the next loop's sentinel-start (e.g. SBJ). When
609+ * SBJ iterates and resets its state between iterations, the old buggy `includes(\`-${nodeId}-\`)`
610+ * check matched edge keys where the sentinel was the TARGET (not the source), wrongly
611+ * reactivating that external edge. That made countActiveIncomingEdges see a phantom pending
612+ * upstream and SBJ's sentinel-start stopped being ready, stalling the loop after iteration 1.
613+ */
614+ it ( 'should not re-activate external cascade-deactivated edges pointing INTO a loop node' , ( ) => {
615+ const externalNodeId = 'external-node'
616+ const sbjSentinelStartId = 'loop-sbj-sentinel-start'
617+ const sbjSentinelEndId = 'loop-sbj-sentinel-end'
618+ const bodyNodeId = 'body-node'
619+
620+ const externalNode = createMockNode ( externalNodeId , [
621+ { target : sbjSentinelStartId , sourceHandle : 'condition-if' } ,
622+ ] )
623+ const sbjSentinelStartNode = createMockNode (
624+ sbjSentinelStartId ,
625+ [ { target : bodyNodeId } ] ,
626+ [ externalNodeId ]
627+ )
628+ const bodyNode = createMockNode (
629+ bodyNodeId ,
630+ [ { target : sbjSentinelEndId } ] ,
631+ [ sbjSentinelStartId ]
632+ )
633+ const sbjSentinelEndNode = createMockNode ( sbjSentinelEndId , [ ] , [ bodyNodeId ] )
634+
635+ const nodes = new Map < string , DAGNode > ( [
636+ [ externalNodeId , externalNode ] ,
637+ [ sbjSentinelStartId , sbjSentinelStartNode ] ,
638+ [ bodyNodeId , bodyNode ] ,
639+ [ sbjSentinelEndId , sbjSentinelEndNode ] ,
640+ ] )
641+
642+ const dag = createMockDAG ( nodes )
643+ const edgeManager = new EdgeManager ( dag )
644+
645+ edgeManager . processOutgoingEdges ( externalNode , { selectedOption : 'else' } )
646+
647+ expect ( edgeManager . isNodeReady ( sbjSentinelStartNode ) ) . toBe ( true )
648+
649+ edgeManager . clearDeactivatedEdgesForNodes (
650+ new Set ( [ sbjSentinelStartId , sbjSentinelEndId , bodyNodeId ] )
651+ )
652+
653+ expect ( edgeManager . isNodeReady ( sbjSentinelStartNode ) ) . toBe ( true )
654+ } )
655+
656+ /**
657+ * End-to-end regression: after a loop reset while an external edge is cascade-deactivated,
658+ * the backwards `loop_continue` edge from sentinel-end must still mark sentinel-start as
659+ * ready. The old code removed the external edge's deactivation entry, leaving a phantom
660+ * active incoming and producing the exact "loop stops after 1 iteration" symptom the user
661+ * hit on the Group A workflow.
662+ */
663+ it ( 'should leave sbjSentinelStart ready after loop reset when external edge is cascade-deactivated' , ( ) => {
664+ const externalNodeId = 'external-node'
665+ const sbjSentinelStartId = 'loop-sbj-sentinel-start'
666+ const sbjSentinelEndId = 'loop-sbj-sentinel-end'
667+ const bodyNodeId = 'body-node'
668+
669+ const externalNode = createMockNode ( externalNodeId , [
670+ { target : sbjSentinelStartId , sourceHandle : 'condition-if' } ,
671+ ] )
672+ const sbjSentinelStartNode = createMockNode (
673+ sbjSentinelStartId ,
674+ [ { target : bodyNodeId } ] ,
675+ [ externalNodeId ]
676+ )
677+ const bodyNode = createMockNode (
678+ bodyNodeId ,
679+ [ { target : sbjSentinelEndId } ] ,
680+ [ sbjSentinelStartId ]
681+ )
682+ const sbjSentinelEndNode = createMockNode (
683+ sbjSentinelEndId ,
684+ [ { target : sbjSentinelStartId , sourceHandle : 'loop_continue' } ] ,
685+ [ bodyNodeId ]
686+ )
687+
688+ const nodes = new Map < string , DAGNode > ( [
689+ [ externalNodeId , externalNode ] ,
690+ [ sbjSentinelStartId , sbjSentinelStartNode ] ,
691+ [ bodyNodeId , bodyNode ] ,
692+ [ sbjSentinelEndId , sbjSentinelEndNode ] ,
693+ ] )
694+
695+ const dag = createMockDAG ( nodes )
696+ const edgeManager = new EdgeManager ( dag )
697+
698+ edgeManager . processOutgoingEdges ( externalNode , { selectedOption : 'else' } )
699+
700+ edgeManager . clearDeactivatedEdgesForNodes (
701+ new Set ( [ sbjSentinelStartId , sbjSentinelEndId , bodyNodeId ] )
702+ )
703+
704+ const readyNodes = edgeManager . processOutgoingEdges ( sbjSentinelEndNode , {
705+ selectedRoute : 'loop_continue' ,
706+ } )
707+
708+ expect ( readyNodes ) . toContain ( sbjSentinelStartId )
709+ } )
710+
711+ /**
712+ * Guard against an overly narrow fix: edges whose SOURCE is inside the loop (e.g. a body
713+ * node that deactivated its outgoing edge during the previous iteration) must still be
714+ * cleared on reset so the next iteration can traverse them.
715+ */
716+ it ( 'should re-activate internal loop edges (source inside loop) when resetting loop state' , ( ) => {
717+ const sbjSentinelStartId = 'loop-sbj-sentinel-start'
718+ const sbjSentinelEndId = 'loop-sbj-sentinel-end'
719+ const conditionInLoopId = 'condition-in-loop'
720+ const thenBranchId = 'then-branch'
721+
722+ const sbjSentinelStartNode = createMockNode ( sbjSentinelStartId , [
723+ { target : conditionInLoopId } ,
724+ ] )
725+ const conditionInLoopNode = createMockNode (
726+ conditionInLoopId ,
727+ [
728+ { target : thenBranchId , sourceHandle : 'condition-if' } ,
729+ { target : sbjSentinelEndId , sourceHandle : 'condition-else' } ,
730+ ] ,
731+ [ sbjSentinelStartId ]
732+ )
733+ const thenBranchNode = createMockNode (
734+ thenBranchId ,
735+ [ { target : sbjSentinelEndId } ] ,
736+ [ conditionInLoopId ]
737+ )
738+ const sbjSentinelEndNode = createMockNode (
739+ sbjSentinelEndId ,
740+ [ ] ,
741+ [ conditionInLoopId , thenBranchId ]
742+ )
743+
744+ const nodes = new Map < string , DAGNode > ( [
745+ [ sbjSentinelStartId , sbjSentinelStartNode ] ,
746+ [ conditionInLoopId , conditionInLoopNode ] ,
747+ [ thenBranchId , thenBranchNode ] ,
748+ [ sbjSentinelEndId , sbjSentinelEndNode ] ,
749+ ] )
750+
751+ const dag = createMockDAG ( nodes )
752+ const edgeManager = new EdgeManager ( dag )
753+
754+ edgeManager . processOutgoingEdges ( conditionInLoopNode , { selectedOption : 'else' } )
755+
756+ edgeManager . clearDeactivatedEdgesForNodes (
757+ new Set ( [ sbjSentinelStartId , sbjSentinelEndId , conditionInLoopId , thenBranchId ] )
758+ )
759+
760+ thenBranchNode . incomingEdges . add ( conditionInLoopId )
761+
762+ const readyNodes = edgeManager . processOutgoingEdges ( conditionInLoopNode , {
763+ selectedOption : 'if' ,
764+ } )
765+
766+ expect ( readyNodes ) . toContain ( thenBranchId )
767+ } )
603768 } )
604769
605770 describe ( 'restoreIncomingEdge' , ( ) => {
0 commit comments