Split out of #24136 (fix for #24060), where both shapes came up but neither is caused by that change.
A step is only evaluated when a parent that reached a terminal status enqueues it, and it can only decide once every parent is terminal. So a parent that can never terminate leaves the child stuck at NOT_STARTED forever. Nothing reports it: the run has no running or pending step left, so it ends COMPLETED with part of the graph untouched.
Two ways to get such a parent.
A cycle inside a branch that was not taken
If/Else A
├ (if) → taken
└ (else) → X → Y → X
Take the if branch. X's parents are the If/Else (dead, since the branch was not taken) and Y. Y's only parent is X. Neither can move. Both end NOT_STARTED and the run reports COMPLETED.
Nothing rejects a cycle: assertWorkflowConnectionOrThrow only checks the handles are defined, and neither createStepEdge nor the workflow validation utils do an acyclicity check, so the edge can be drawn in the editor.
shouldExecuteIteratorStep already solves exactly this for loops by filtering stepIdsInLoop out of the parents it considers. The same idea generalises: a parent that is a descendant of the step is a back edge and must not block it. In an acyclic graph that filter changes nothing, so it only affects the broken case.
A step made unreachable by graph editing that still points at a live step
deleteStepEdge does no reachability check, and a step that loses its last incoming edge stays on the canvas and keeps its own nextStepIds. It can never run, so any step it points at can never conclude. This one already misbehaves on main.
Here the back-edge filter does not help; it needs the executor to treat a parent that is unreachable from the trigger as dead, or the builder to stop producing the shape.
Both need a user to draw an unusual graph, which is why they were left out of #24136.
Split out of #24136 (fix for #24060), where both shapes came up but neither is caused by that change.
A step is only evaluated when a parent that reached a terminal status enqueues it, and it can only decide once every parent is terminal. So a parent that can never terminate leaves the child stuck at
NOT_STARTEDforever. Nothing reports it: the run has no running or pending step left, so it endsCOMPLETEDwith part of the graph untouched.Two ways to get such a parent.
A cycle inside a branch that was not taken
Take the
ifbranch. X's parents are the If/Else (dead, since the branch was not taken) and Y. Y's only parent is X. Neither can move. Both endNOT_STARTEDand the run reportsCOMPLETED.Nothing rejects a cycle:
assertWorkflowConnectionOrThrowonly checks the handles are defined, and neithercreateStepEdgenor the workflow validation utils do an acyclicity check, so the edge can be drawn in the editor.shouldExecuteIteratorStepalready solves exactly this for loops by filteringstepIdsInLoopout of the parents it considers. The same idea generalises: a parent that is a descendant of the step is a back edge and must not block it. In an acyclic graph that filter changes nothing, so it only affects the broken case.A step made unreachable by graph editing that still points at a live step
deleteStepEdgedoes no reachability check, and a step that loses its last incoming edge stays on the canvas and keeps its ownnextStepIds. It can never run, so any step it points at can never conclude. This one already misbehaves on main.Here the back-edge filter does not help; it needs the executor to treat a parent that is unreachable from the trigger as dead, or the builder to stop producing the shape.
Both need a user to draw an unusual graph, which is why they were left out of #24136.