Add conditional block support for script caching (v2 - with bug fix) (#4642)

This commit is contained in:
pedrohsdb
2026-02-05 11:42:29 -08:00
committed by GitHub
parent 7e978bba36
commit 5fd4263847
7 changed files with 307 additions and 115 deletions

View File

@@ -20,8 +20,6 @@ from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.schemas.tasks import Task, TaskStatus
from skyvern.forge.sdk.trace import traced
from skyvern.forge.sdk.workflow.models.block import BlockTypeVar
from skyvern.services import workflow_script_service
from skyvern.webeye.actions.action_types import POST_ACTION_EXECUTION_ACTION_TYPES
from skyvern.webeye.actions.actions import Action
from skyvern.webeye.browser_state import BrowserState
from skyvern.webeye.scraper.scraped_page import ELEMENT_NODE_ATTRIBUTES, CleanupElementTreeFunc, json_to_html
@@ -601,42 +599,12 @@ class AgentFunction:
if not settings.ENABLE_CODE_BLOCK:
raise DisabledBlockExecutionError("CodeBlock is disabled")
# TODO: Remove these methods if nothing calls them after verifying in production
async def _post_action_execution(self, action: Action) -> None:
"""
If this is a workflow running environment, generate the
"""
if action.action_type not in POST_ACTION_EXECUTION_ACTION_TYPES:
return
context = skyvern_context.current()
if (
not context
or not context.root_workflow_run_id
or not context.organization_id
or not context.generate_script
):
return
root_workflow_run_id = context.root_workflow_run_id
organization_id = context.organization_id
workflow_run = await app.DATABASE.get_workflow_run(
workflow_run_id=root_workflow_run_id, organization_id=organization_id
)
if not workflow_run:
return
workflow = await app.DATABASE.get_workflow(
workflow_id=workflow_run.workflow_id, organization_id=organization_id
)
if not workflow:
return
LOG.info(
"Post action execution",
root_workflow_run_id=context.root_workflow_run_id,
organization_id=context.organization_id,
)
"""Post-action hook - now a no-op.
await workflow_script_service.generate_or_update_pending_workflow_script(
workflow_run=workflow_run,
workflow=workflow,
)
Script generation moved to block-level via _generate_pending_script_for_block() in service.py.
"""
async def post_action_execution(self, action: Action) -> None:
asyncio.create_task(self._post_action_execution(action))
pass

View File

@@ -577,10 +577,11 @@ class AgentDB(BaseAlchemyDB):
select(ActionModel)
.filter(ActionModel.organization_id == organization_id)
.filter(ActionModel.task_id.in_(task_ids))
.order_by(ActionModel.created_at.desc())
.order_by(ActionModel.created_at)
)
actions = (await session.scalars(query)).all()
return [hydrate_action(action, empty_element_id=True) for action in actions]
# Must match get_task_actions_hydrated: no empty_element_id so None element_ids stay None
return [hydrate_action(action) for action in actions]
except SQLAlchemyError:
LOG.error("SQLAlchemyError", exc_info=True)

View File

@@ -695,6 +695,7 @@ class WorkflowService:
)
workflow_run = await self.get_workflow_run(workflow_run_id=workflow_run_id, organization_id=organization_id)
workflow = await self.get_workflow_by_permanent_id(workflow_permanent_id=workflow_run.workflow_permanent_id)
has_conditionals = workflow_script_service.workflow_has_conditionals(workflow)
browser_profile_id = workflow_run.browser_profile_id
close_browser_on_completion = browser_session_id is None and not workflow_run.browser_address
@@ -853,6 +854,7 @@ class WorkflowService:
block_labels=block_labels,
blocks_to_update=blocks_to_update,
finalize=True, # Force regeneration to ensure field mappings have complete action data
has_conditionals=has_conditionals,
)
# Execute finally block if configured. Skip for: canceled (user explicitly stopped)
@@ -1035,6 +1037,7 @@ class WorkflowService:
should_stop,
_,
) = await self._execute_single_block(
workflow=workflow,
block=block,
block_idx=block_idx,
blocks_cnt=blocks_cnt,
@@ -1052,6 +1055,56 @@ class WorkflowService:
break
return workflow_run, blocks_to_update
async def _generate_pending_script_for_block(
self,
workflow: Workflow,
workflow_run: WorkflowRun,
block_result: BlockResult | None,
) -> None:
"""Generate pending script after a block completes successfully.
This is called after each block execution instead of after each action,
reducing script generation frequency while maintaining progressive updates.
Uses asyncio.create_task() to avoid adding latency between blocks.
"""
if not block_result or block_result.status != BlockStatus.completed:
return
context = skyvern_context.current()
if not context or not context.generate_script:
return
disable_script_generation = await app.EXPERIMENTATION_PROVIDER.is_feature_enabled_cached(
"DISABLE_GENERATE_SCRIPT_AFTER_BLOCK",
workflow_run.workflow_run_id,
properties={"organization_id": workflow_run.organization_id},
)
if disable_script_generation:
return
asyncio.create_task(
self._do_generate_pending_script(workflow, workflow_run),
name=f"script_gen_{workflow_run.workflow_run_id}",
)
async def _do_generate_pending_script(
self,
workflow: Workflow,
workflow_run: WorkflowRun,
) -> None:
"""Fire-and-forget wrapper for pending script generation with error handling."""
try:
await workflow_script_service.generate_or_update_pending_workflow_script(
workflow_run=workflow_run,
workflow=workflow,
)
except Exception:
LOG.warning(
"Failed to generate pending script after block completion",
workflow_run_id=workflow_run.workflow_run_id,
exc_info=True,
)
async def _execute_workflow_blocks_dag(
self,
*,
@@ -1102,6 +1155,7 @@ class WorkflowService:
should_stop,
branch_metadata,
) = await self._execute_single_block(
workflow=workflow,
block=block,
block_idx=block_idx,
blocks_cnt=total_blocks,
@@ -1155,6 +1209,7 @@ class WorkflowService:
async def _execute_single_block(
self,
*,
workflow: Workflow,
block: BlockTypeVar,
block_idx: int,
blocks_cnt: int,
@@ -1325,6 +1380,10 @@ class WorkflowService:
workflow_run=workflow_run,
workflow_run_id=workflow_run_id,
)
# Generate pending script after block completes successfully
await self._generate_pending_script_for_block(workflow, workflow_run, workflow_run_block_result)
return workflow_run, blocks_to_update, workflow_run_block_result, should_stop, branch_metadata
except Exception as e:
@@ -3357,6 +3416,7 @@ class WorkflowService:
block_labels: list[str] | None = None,
blocks_to_update: set[str] | None = None,
finalize: bool = False,
has_conditionals: bool | None = None,
) -> None:
"""
Generate or regenerate workflow script if needed.
@@ -3369,6 +3429,7 @@ class WorkflowService:
finalize: If True, check if any actions were skipped during script generation
due to missing data (race condition). Only regenerate if needed.
This fixes SKY-7653 while avoiding unnecessary regeneration costs.
has_conditionals: Whether the workflow has conditional blocks. If None, will be computed.
"""
code_gen = workflow_run.code_gen
blocks_to_update = set(blocks_to_update or [])
@@ -3435,12 +3496,28 @@ class WorkflowService:
should_cache_block_labels.add(settings.WORKFLOW_START_BLOCK_LABEL)
cached_block_labels.add(settings.WORKFLOW_START_BLOCK_LABEL)
# For workflows with conditional blocks, "missing" labels from unexecuted branches
# should NOT trigger regeneration. They will be cached when those branches execute.
# This prevents the bug where every run triggers unnecessary regeneration because
# blocks from unexecuted branches are always "missing".
if has_conditionals is None:
has_conditionals = workflow_script_service.workflow_has_conditionals(workflow)
if cached_block_labels != should_cache_block_labels:
missing_labels = should_cache_block_labels - cached_block_labels
if missing_labels:
if missing_labels and not has_conditionals:
# Only add missing labels for workflows WITHOUT conditionals.
# For workflows WITH conditionals, missing labels are expected (unexecuted branches).
blocks_to_update.update(missing_labels)
# Always rebuild the orchestrator if the definition changed
blocks_to_update.add(settings.WORKFLOW_START_BLOCK_LABEL)
# Always rebuild the orchestrator if the definition changed
blocks_to_update.add(settings.WORKFLOW_START_BLOCK_LABEL)
elif missing_labels and has_conditionals:
LOG.debug(
"Skipping regeneration for missing labels in workflow with conditionals",
workflow_id=workflow.workflow_id,
workflow_run_id=workflow_run.workflow_run_id,
missing_labels=list(missing_labels),
)
should_regenerate = bool(blocks_to_update) or bool(code_gen)