mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
Fix workflow stall after nested conditional with no merge point (SKY-8571) (#5257)
This commit is contained in:
parent
52d5825e14
commit
11400c454d
2 changed files with 406 additions and 1 deletions
|
|
@ -1701,6 +1701,19 @@ class WorkflowService:
|
|||
next_label = None
|
||||
if block.block_type == BlockType.CONDITIONAL:
|
||||
next_label = (branch_metadata or {}).get("next_block_label")
|
||||
if not next_label:
|
||||
# SKY-8571: Fall back to the conditional block's own
|
||||
# next_block_label when the matched branch has no target
|
||||
# (e.g., default branch with no redirect, failed evaluation
|
||||
# with continue_on_failure, or finally-block stripping).
|
||||
next_label = default_next_map.get(block.label)
|
||||
if next_label:
|
||||
LOG.info(
|
||||
"Conditional branch has no next_block_label, falling back to block's own next_block_label",
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
block_label=block.label,
|
||||
fallback_next_label=next_label,
|
||||
)
|
||||
else:
|
||||
next_label = default_next_map.get(block.label)
|
||||
|
||||
|
|
@ -2652,6 +2665,47 @@ class WorkflowService:
|
|||
if default_next_map.get(block.label) is None:
|
||||
default_next_map[block.label] = blocks[idx + 1].label
|
||||
|
||||
# SKY-8571: Connect terminal blocks in conditional branch chains to the
|
||||
# conditional's successor (merge-point block).
|
||||
#
|
||||
# Bug scenario: nested conditionals where the inner conditional has no
|
||||
# merge-point (next_block_label=null). The outer conditional's branch
|
||||
# chain ends at the inner conditional, whose own branches terminate
|
||||
# without reconnecting to the outer merge-point.
|
||||
#
|
||||
# The fix iterates until convergence because patching an outer
|
||||
# conditional may give an inner conditional a successor, which in turn
|
||||
# lets the inner conditional's branch terminals be patched on the next
|
||||
# pass. E.g.:
|
||||
# Pass 1: outer_cond patches inner_cond.next → outer_merge
|
||||
# Pass 2: inner_cond (now has successor) patches block_57.next → outer_merge
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for block in all_blocks:
|
||||
if not isinstance(block, ConditionalBlock):
|
||||
continue
|
||||
successor = default_next_map.get(block.label)
|
||||
if not successor:
|
||||
continue
|
||||
for branch in block.ordered_branches:
|
||||
target = branch.next_block_label
|
||||
if not target or target == successor:
|
||||
continue
|
||||
# Trace the branch chain via default_next_map to find the terminal block.
|
||||
cur = target
|
||||
visited: set[str] = set()
|
||||
while cur and cur in label_to_block and cur not in visited:
|
||||
if cur == successor:
|
||||
break
|
||||
visited.add(cur)
|
||||
nxt = default_next_map.get(cur)
|
||||
if nxt is None:
|
||||
default_next_map[cur] = successor
|
||||
changed = True
|
||||
break
|
||||
cur = nxt
|
||||
|
||||
adjacency: dict[str, set[str]] = {label: set() for label in label_to_block}
|
||||
incoming: dict[str, int] = {label: 0 for label in label_to_block}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from skyvern.forge.sdk.workflow.models.block import (
|
|||
)
|
||||
from skyvern.forge.sdk.workflow.models.parameter import OutputParameter
|
||||
from skyvern.forge.sdk.workflow.service import WorkflowService
|
||||
from skyvern.schemas.workflows import BlockStatus
|
||||
from skyvern.schemas.workflows import BlockStatus, BlockType
|
||||
|
||||
|
||||
def _output_parameter(key: str) -> OutputParameter:
|
||||
|
|
@ -247,3 +247,354 @@ async def test_prompt_branch_uses_batched_evaluation(monkeypatch: pytest.MonkeyP
|
|||
assert metadata["branch_taken"] == "next"
|
||||
assert metadata["criteria_type"] == "prompt"
|
||||
prompt_eval_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def test_build_workflow_graph_stores_conditional_own_next_block_label() -> None:
|
||||
"""Verify default_next_map stores the conditional block's own next_block_label.
|
||||
|
||||
SKY-8571: This is the prerequisite for the DAG fallback — when a matched
|
||||
branch has next_block_label=None, the engine must be able to look up the
|
||||
conditional block's own next_block_label in default_next_map.
|
||||
"""
|
||||
service = WorkflowService()
|
||||
|
||||
conditional = _conditional_block(
|
||||
"conditional",
|
||||
branch_conditions=[
|
||||
BranchCondition(criteria=JinjaBranchCriteria(expression="{{ flag }}"), next_block_label="branch_target"),
|
||||
BranchCondition(criteria=None, next_block_label=None, is_default=True),
|
||||
],
|
||||
next_block_label="after_conditional",
|
||||
)
|
||||
branch_target = _navigation_block("branch_target", next_block_label="after_conditional")
|
||||
after = _navigation_block("after_conditional")
|
||||
|
||||
_, _, default_next_map = service._build_workflow_graph([conditional, branch_target, after])
|
||||
|
||||
assert default_next_map["conditional"] == "after_conditional"
|
||||
|
||||
|
||||
def test_build_workflow_graph_patches_terminal_blocks_in_conditional_branches() -> None:
|
||||
"""SKY-8571: Terminal blocks at the end of a conditional branch chain
|
||||
should be patched to point to the conditional's successor (merge-point).
|
||||
|
||||
This models the real customer bug: a conditional routes to a chain of blocks
|
||||
(branch_start → middle → terminal), and the terminal block has no
|
||||
next_block_label set. The conditional's own next_block_label IS the
|
||||
merge-point, so the terminal should be connected to it automatically.
|
||||
"""
|
||||
service = WorkflowService()
|
||||
|
||||
conditional = _conditional_block(
|
||||
"cond",
|
||||
branch_conditions=[
|
||||
BranchCondition(
|
||||
criteria=JinjaBranchCriteria(expression="{{ flag }}"),
|
||||
next_block_label="branch_start",
|
||||
),
|
||||
# Default branch goes directly to merge point (no inner blocks)
|
||||
BranchCondition(criteria=None, next_block_label="merge_point", is_default=True),
|
||||
],
|
||||
next_block_label="merge_point",
|
||||
)
|
||||
# Branch chain: branch_start → middle → terminal (no next_block_label!)
|
||||
branch_start = _navigation_block("branch_start", next_block_label="middle")
|
||||
middle = _extraction_block("middle", next_block_label="terminal")
|
||||
terminal = _navigation_block("terminal") # No next_block_label
|
||||
merge_point = _navigation_block("merge_point")
|
||||
|
||||
_, _, default_next_map = service._build_workflow_graph([conditional, branch_start, middle, terminal, merge_point])
|
||||
|
||||
# The terminal block should be patched to point to the merge point
|
||||
assert default_next_map["terminal"] == "merge_point"
|
||||
# Existing connections should be preserved
|
||||
assert default_next_map["branch_start"] == "middle"
|
||||
assert default_next_map["middle"] == "terminal"
|
||||
assert default_next_map["merge_point"] is None
|
||||
|
||||
|
||||
def test_build_workflow_graph_patches_nested_conditional_no_merge_point() -> None:
|
||||
"""SKY-8571: This models the actual customer bug.
|
||||
|
||||
outer_cond (next=outer_merge) has a branch leading to:
|
||||
block_29 → inner_cond (next=null)
|
||||
inner_cond has NO merge point. Its branches lead to:
|
||||
branch 0 → loop → wait → validate (terminal)
|
||||
default → other_loop (terminal)
|
||||
|
||||
After iterative patching:
|
||||
Pass 1: outer_cond patches inner_cond.next → outer_merge
|
||||
Pass 2: inner_cond (now has successor) patches validate.next → outer_merge
|
||||
AND other_loop.next → outer_merge
|
||||
"""
|
||||
service = WorkflowService()
|
||||
|
||||
outer_cond = _conditional_block(
|
||||
"outer_cond",
|
||||
branch_conditions=[
|
||||
BranchCondition(
|
||||
criteria=JinjaBranchCriteria(expression="{{ flag }}"),
|
||||
next_block_label="block_29",
|
||||
),
|
||||
BranchCondition(criteria=None, next_block_label="outer_merge", is_default=True),
|
||||
],
|
||||
next_block_label="outer_merge",
|
||||
)
|
||||
block_29 = _navigation_block("block_29", next_block_label="inner_cond")
|
||||
inner_cond = _conditional_block(
|
||||
"inner_cond",
|
||||
branch_conditions=[
|
||||
BranchCondition(
|
||||
criteria=JinjaBranchCriteria(expression="{{ x }}"),
|
||||
next_block_label="loop_block",
|
||||
),
|
||||
BranchCondition(criteria=None, next_block_label="other_loop", is_default=True),
|
||||
],
|
||||
next_block_label=None, # No merge point!
|
||||
)
|
||||
loop_block = _navigation_block("loop_block", next_block_label="wait_block")
|
||||
wait_block = _extraction_block("wait_block", next_block_label="validate")
|
||||
validate = _navigation_block("validate") # Terminal, next=None
|
||||
other_loop = _navigation_block("other_loop") # Terminal, next=None
|
||||
outer_merge = _navigation_block("outer_merge")
|
||||
|
||||
_, _, default_next_map = service._build_workflow_graph(
|
||||
[outer_cond, block_29, inner_cond, loop_block, wait_block, validate, other_loop, outer_merge]
|
||||
)
|
||||
|
||||
# Pass 1: outer_cond patches inner_cond (terminal in its branch chain)
|
||||
assert default_next_map["inner_cond"] == "outer_merge"
|
||||
# Pass 2: inner_cond now has successor, patches its own branch terminals
|
||||
assert default_next_map["validate"] == "outer_merge"
|
||||
assert default_next_map["other_loop"] == "outer_merge"
|
||||
# outer_merge itself stays terminal
|
||||
assert default_next_map["outer_merge"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dag_conditional_fallback_to_own_next_block_label(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""SKY-8571: When a conditional block's matched branch has next_block_label=None,
|
||||
the DAG should fall back to the conditional block's own next_block_label
|
||||
instead of treating it as a terminal node and stopping execution.
|
||||
|
||||
Scenario: conditional has two branches — one that points to an inner block
|
||||
and a default with no target. The conditional's own next_block_label serves
|
||||
as the merge/continuation point. At runtime the default branch matches,
|
||||
returning next_block_label=None. The engine should fall back.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from skyvern.forge.sdk.workflow.models.block import WaitBlock
|
||||
from skyvern.schemas.workflows import BlockResult
|
||||
|
||||
service = WorkflowService()
|
||||
|
||||
# Workflow structure:
|
||||
# conditional ──branch_a──► inner_block ──► wait_block
|
||||
# └─default(None) ▲
|
||||
# └─(own next_block_label)─────┘
|
||||
# The default branch has next_block_label=None, but the conditional's
|
||||
# own next_block_label points to wait_block as the merge point.
|
||||
conditional = _conditional_block(
|
||||
"conditional",
|
||||
branch_conditions=[
|
||||
BranchCondition(criteria=JinjaBranchCriteria(expression="{{ flag }}"), next_block_label="inner_block"),
|
||||
BranchCondition(criteria=None, next_block_label=None, is_default=True),
|
||||
],
|
||||
next_block_label="wait_block",
|
||||
)
|
||||
inner = _navigation_block("inner_block", next_block_label="wait_block")
|
||||
wait = WaitBlock(
|
||||
label="wait_block",
|
||||
output_parameter=_output_parameter("wait_output"),
|
||||
wait_sec=1,
|
||||
)
|
||||
|
||||
workflow = MagicMock()
|
||||
workflow.workflow_definition = MagicMock()
|
||||
workflow.workflow_definition.blocks = [conditional, inner, wait]
|
||||
workflow.workflow_definition.finally_block_label = None
|
||||
workflow.workflow_permanent_id = "wpid_test"
|
||||
workflow.workflow_id = "wf_test"
|
||||
workflow.generate_script_on_terminal = False
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.workflow_run_id = "wr_test"
|
||||
|
||||
organization = MagicMock()
|
||||
organization.organization_id = "org_test"
|
||||
|
||||
# Track which blocks were executed
|
||||
executed_blocks: list[str] = []
|
||||
|
||||
async def mock_execute_single_block(
|
||||
*,
|
||||
workflow,
|
||||
block,
|
||||
block_idx,
|
||||
blocks_cnt,
|
||||
workflow_run,
|
||||
organization,
|
||||
workflow_run_id,
|
||||
browser_session_id,
|
||||
script_blocks_by_label,
|
||||
loaded_script_module,
|
||||
is_script_run,
|
||||
blocks_to_update,
|
||||
parent_workflow_run_block_id=None,
|
||||
):
|
||||
executed_blocks.append(block.label)
|
||||
branch_metadata = None
|
||||
if block.block_type == BlockType.CONDITIONAL:
|
||||
# Simulate: default branch matched → next_block_label is None
|
||||
branch_metadata = {
|
||||
"branch_taken": None,
|
||||
"branch_index": 1,
|
||||
"next_block_label": None,
|
||||
}
|
||||
block_result = BlockResult(
|
||||
success=True,
|
||||
output_parameter=block.output_parameter,
|
||||
output_parameter_value=branch_metadata,
|
||||
status=BlockStatus.completed,
|
||||
workflow_run_block_id=f"wrb_{block.label}",
|
||||
)
|
||||
return workflow_run, blocks_to_update, block_result, False, branch_metadata
|
||||
|
||||
monkeypatch.setattr(service, "_execute_single_block", mock_execute_single_block)
|
||||
|
||||
result_run, _ = await service._execute_workflow_blocks_dag(
|
||||
workflow=workflow,
|
||||
workflow_run=workflow_run,
|
||||
organization=organization,
|
||||
browser_session_id=None,
|
||||
script_blocks_by_label={},
|
||||
loaded_script_module=None,
|
||||
is_script_run=False,
|
||||
blocks_to_update=set(),
|
||||
)
|
||||
|
||||
# The critical assertion: both blocks should have been executed.
|
||||
# Before the fix, only "conditional" would be in the list because
|
||||
# the DAG treated next_block_label=None as a terminal node.
|
||||
assert executed_blocks == ["conditional", "wait_block"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dag_execution_continues_after_nested_conditional_branch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""SKY-8571: Models the actual customer bug — nested conditionals where the
|
||||
inner conditional has no merge point.
|
||||
|
||||
Structure:
|
||||
outer_cond (next=merge) → branch → nav → inner_cond (next=null)
|
||||
inner_cond → branch → loop_block → validate (next=null)
|
||||
merge (wait block)
|
||||
|
||||
Execution: outer_cond → nav → inner_cond → loop_block → validate → merge
|
||||
Before the fix, execution stalls at validate because both validate and
|
||||
inner_cond have next_block_label=null.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from skyvern.forge.sdk.workflow.models.block import WaitBlock
|
||||
from skyvern.schemas.workflows import BlockResult
|
||||
|
||||
service = WorkflowService()
|
||||
|
||||
outer_cond = _conditional_block(
|
||||
"outer_cond",
|
||||
branch_conditions=[
|
||||
BranchCondition(
|
||||
criteria=JinjaBranchCriteria(expression="{{ flag }}"),
|
||||
next_block_label="nav",
|
||||
),
|
||||
BranchCondition(criteria=None, next_block_label="merge", is_default=True),
|
||||
],
|
||||
next_block_label="merge",
|
||||
)
|
||||
nav = _navigation_block("nav", next_block_label="inner_cond")
|
||||
inner_cond = _conditional_block(
|
||||
"inner_cond",
|
||||
branch_conditions=[
|
||||
BranchCondition(
|
||||
criteria=JinjaBranchCriteria(expression="{{ x }}"),
|
||||
next_block_label="loop_block",
|
||||
),
|
||||
BranchCondition(criteria=None, next_block_label="other_path", is_default=True),
|
||||
],
|
||||
next_block_label=None, # No merge point!
|
||||
)
|
||||
loop_block = _navigation_block("loop_block", next_block_label="validate")
|
||||
validate = _extraction_block("validate") # Terminal — next=None
|
||||
other_path = _navigation_block("other_path") # Terminal — next=None
|
||||
merge = WaitBlock(
|
||||
label="merge",
|
||||
output_parameter=_output_parameter("merge_output"),
|
||||
wait_sec=1,
|
||||
)
|
||||
|
||||
workflow = MagicMock()
|
||||
workflow.workflow_definition = MagicMock()
|
||||
workflow.workflow_definition.blocks = [outer_cond, nav, inner_cond, loop_block, validate, other_path, merge]
|
||||
workflow.workflow_definition.finally_block_label = None
|
||||
workflow.workflow_permanent_id = "wpid_test"
|
||||
workflow.workflow_id = "wf_test"
|
||||
workflow.generate_script_on_terminal = False
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.workflow_run_id = "wr_test"
|
||||
|
||||
organization = MagicMock()
|
||||
organization.organization_id = "org_test"
|
||||
|
||||
executed_blocks: list[str] = []
|
||||
|
||||
# Map conditional labels to the branch_metadata they should return
|
||||
branch_responses = {
|
||||
"outer_cond": {"branch_taken": "nav", "branch_index": 0, "next_block_label": "nav"},
|
||||
"inner_cond": {"branch_taken": "loop_block", "branch_index": 0, "next_block_label": "loop_block"},
|
||||
}
|
||||
|
||||
async def mock_execute_single_block(
|
||||
*,
|
||||
workflow,
|
||||
block,
|
||||
block_idx,
|
||||
blocks_cnt,
|
||||
workflow_run,
|
||||
organization,
|
||||
workflow_run_id,
|
||||
browser_session_id,
|
||||
script_blocks_by_label,
|
||||
loaded_script_module,
|
||||
is_script_run,
|
||||
blocks_to_update,
|
||||
parent_workflow_run_block_id=None,
|
||||
):
|
||||
executed_blocks.append(block.label)
|
||||
branch_metadata = branch_responses.get(block.label)
|
||||
block_result = BlockResult(
|
||||
success=True,
|
||||
output_parameter=block.output_parameter,
|
||||
output_parameter_value=branch_metadata,
|
||||
status=BlockStatus.completed,
|
||||
workflow_run_block_id=f"wrb_{block.label}",
|
||||
)
|
||||
return workflow_run, blocks_to_update, block_result, False, branch_metadata
|
||||
|
||||
monkeypatch.setattr(service, "_execute_single_block", mock_execute_single_block)
|
||||
|
||||
result_run, _ = await service._execute_workflow_blocks_dag(
|
||||
workflow=workflow,
|
||||
workflow_run=workflow_run,
|
||||
organization=organization,
|
||||
browser_session_id=None,
|
||||
script_blocks_by_label={},
|
||||
loaded_script_module=None,
|
||||
is_script_run=False,
|
||||
blocks_to_update=set(),
|
||||
)
|
||||
|
||||
# All blocks should execute through to merge.
|
||||
# Before the fix, execution stalled at validate (next=null).
|
||||
assert executed_blocks == ["outer_cond", "nav", "inner_cond", "loop_block", "validate", "merge"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue