pyworkflow-engine 0.3.7__tar.gz → 0.3.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/PKG-INFO +8 -8
- pyworkflow_engine-0.3.8/docs/concepts/step-hooks-and-checkpointing.mdx +325 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyproject.toml +8 -8
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/__init__.py +1 -1
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/tasks.py +35 -4
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/base.py +35 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/step.py +3 -1
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/executor.py +91 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/step_checkpoint.py +27 -2
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/step_hook.py +19 -3
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/local.py +48 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/memory.py +5 -2
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow_engine.egg-info/SOURCES.txt +2 -0
- pyworkflow_engine-0.3.8/tests/integration/test_step_hook_inline_suspension_e2e.py +285 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step_hook.py +363 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/CLAUDE.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/DISTRIBUTED.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/LICENSE +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/MANIFEST.in +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/README.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/RELEASING.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/architecture.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/cancellation.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/continue-as-new.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/events.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/fault-tolerance.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/hooks.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/limitations.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/schedules.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/sleep.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/step-context.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/steps.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/stream-steps.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/stream-workflows.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/workflows.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/conventions.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/guides/brokers.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/guides/cli.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/guides/configuration.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/harness-gaps.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/introduction.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/layers.md +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/quickstart.mdx +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/docker-compose.yml +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/basic.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/batch_processing.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/cancellation.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/child_workflows.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/hooks.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/idempotency.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/long_running.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/retries.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/schedules.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/step_context.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/02_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/02_file_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/03_retries.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/04_long_running.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/05_event_log.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/06_idempotency.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/07_hooks.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/08_cancellation.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/09_child_workflows.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/10_child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/11_continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/12_schedules.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/13_step_context.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/14_child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/01_quick_tasks.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/02_retries.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/03_sleep.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/context.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/handler.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/testing.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/app.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/loop.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/reschedule.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/scheduler.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/singleton.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/__main__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/hooks.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/quickstart.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/runs.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/scheduler.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/schedules.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/setup.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/worker.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/workflows.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/output/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/output/formatters.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/output/styles.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/async_helpers.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/config.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/config_generator.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/discovery.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/docker_manager.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/interactive.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/config.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/aws.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/local.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/mock.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/step_context.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/exceptions.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/registry.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/scheduled.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/validation.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/workflow.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/discovery.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/events.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/replay.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/observability/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/observability/logging.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/child_handle.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/child_workflow.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/define_hook.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/hooks.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/resume_hook.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/schedule.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/shield.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/sleep.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/base.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/celery.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/factory.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/scheduler/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/scheduler/local.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/serialization/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/serialization/decoder.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/serialization/encoder.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/base.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/cassandra.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/citus.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/config.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/dynamodb.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/file.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/migrations/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/migrations/base.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/mysql.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/postgres.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/schemas.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/sqlite.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/checkpoint.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/consumer.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/context.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/decorator.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/dispatcher.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/emit.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/registry.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/runtime.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/signal.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/step_context.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/base.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/factory.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/langfuse.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/types.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/duration.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/helpers.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/schedule.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/setup.cfg +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/_reschedule_e2e_app.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_cancellation.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_list_runs_pagination.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_reschedule.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_reschedule_e2e.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_schedule_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_schema_migrations.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_singleton.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_stream_e2e.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_workflow_suspended.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_citus_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_postgres_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_sqlite_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/conftest.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/storage/__init__.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/storage/test_migrations.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_cancellation.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_cli_worker.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_emit.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_event_limits.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_executor.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_force_local.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_hooks.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_parent_run_id.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_primitives_from_steps.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_registry.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_replay.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_reschedule.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_retention.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_schedule_schemas.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_schedule_utils.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_scheduled_workflow.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_signal.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_singleton.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step_checkpoint.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step_context.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_stream_storage.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_stream_workflow.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_validation.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_workflow.py +0 -0
- {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_workflow_suspended.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyworkflow-engine
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
4
4
|
Summary: A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow
|
|
5
5
|
Author: PyWorkflow Contributors
|
|
6
6
|
License: MIT
|
|
@@ -26,13 +26,13 @@ Requires-Dist: celery<6.0.0,>=5.3.0
|
|
|
26
26
|
Requires-Dist: cloudpickle>=3.0.0
|
|
27
27
|
Requires-Dist: pydantic<3.0.0,>=2.0.0
|
|
28
28
|
Requires-Dist: loguru>=0.7.0
|
|
29
|
-
Requires-Dist: click>=8.4.
|
|
29
|
+
Requires-Dist: click>=8.4.2
|
|
30
30
|
Requires-Dist: inquirerpy>=0.3.4; python_version < "4.0"
|
|
31
31
|
Requires-Dist: httpx>=0.25.0
|
|
32
32
|
Requires-Dist: python-dateutil>=2.8.0
|
|
33
|
-
Requires-Dist: filelock>=3.29.
|
|
33
|
+
Requires-Dist: filelock>=3.29.5
|
|
34
34
|
Requires-Dist: pyyaml>=6.0.0
|
|
35
|
-
Requires-Dist: croniter>=2.
|
|
35
|
+
Requires-Dist: croniter>=6.2.3
|
|
36
36
|
Provides-Extra: redis
|
|
37
37
|
Requires-Dist: redis>=5.0.0; extra == "redis"
|
|
38
38
|
Provides-Extra: sqlite
|
|
@@ -40,7 +40,7 @@ Requires-Dist: aiosqlite>=0.19.0; extra == "sqlite"
|
|
|
40
40
|
Provides-Extra: postgres
|
|
41
41
|
Requires-Dist: asyncpg>=0.29.0; extra == "postgres"
|
|
42
42
|
Provides-Extra: aws
|
|
43
|
-
Requires-Dist: aws-durable-execution-sdk-python>=
|
|
43
|
+
Requires-Dist: aws-durable-execution-sdk-python>=1.6.0; extra == "aws"
|
|
44
44
|
Provides-Extra: dynamodb
|
|
45
45
|
Requires-Dist: aiobotocore>=3.7.0; extra == "dynamodb"
|
|
46
46
|
Provides-Extra: cassandra
|
|
@@ -52,17 +52,17 @@ Requires-Dist: redis>=5.0.0; extra == "all"
|
|
|
52
52
|
Requires-Dist: aiosqlite>=0.19.0; extra == "all"
|
|
53
53
|
Requires-Dist: asyncpg>=0.29.0; extra == "all"
|
|
54
54
|
Requires-Dist: aiomysql>=0.2.0; extra == "all"
|
|
55
|
-
Requires-Dist: aws-durable-execution-sdk-python>=
|
|
55
|
+
Requires-Dist: aws-durable-execution-sdk-python>=1.6.0; extra == "all"
|
|
56
56
|
Requires-Dist: cassandra-driver>=3.29.0; extra == "all"
|
|
57
57
|
Provides-Extra: dev
|
|
58
|
-
Requires-Dist: pytest>=
|
|
58
|
+
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
59
59
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
60
60
|
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
61
61
|
Requires-Dist: pytest-celery>=0.0.0; extra == "dev"
|
|
62
62
|
Requires-Dist: pytest-mock>=3.12.0; extra == "dev"
|
|
63
63
|
Requires-Dist: moto[dynamodb]>=5.2.2; extra == "dev"
|
|
64
64
|
Requires-Dist: black>=26.5.1; extra == "dev"
|
|
65
|
-
Requires-Dist: ruff>=0.15.
|
|
65
|
+
Requires-Dist: ruff>=0.15.20; extra == "dev"
|
|
66
66
|
Requires-Dist: mypy>=2.1.0; extra == "dev"
|
|
67
67
|
Requires-Dist: pre-commit>=4.6.0; extra == "dev"
|
|
68
68
|
Requires-Dist: types-redis>=4.6.0; extra == "dev"
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Step Hooks & Checkpointing'
|
|
3
|
+
description: 'Durably suspend and resume from *inside* a @step — the correct way to do human-in-the-loop for AI-agent steps'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<Note>
|
|
7
|
+
This supersedes the restriction stated in [Hooks](/concepts/hooks): *"Hooks
|
|
8
|
+
can only be called from workflow-level code, not from within `@step`
|
|
9
|
+
functions."* That is true of the workflow-level `hook()`. The
|
|
10
|
+
**`step_hook()`** primitive documented here exists precisely to give a step
|
|
11
|
+
the same durable suspend/resume, by re-executing the step and replaying its
|
|
12
|
+
state from a checkpoint. Use it when the thing that must wait for an external
|
|
13
|
+
event lives deep inside a step (e.g. a tool call inside an agent loop) and
|
|
14
|
+
cannot be lifted to the workflow level.
|
|
15
|
+
</Note>
|
|
16
|
+
|
|
17
|
+
## The problem this solves
|
|
18
|
+
|
|
19
|
+
A step runs to completion on one worker. If it needs to wait for an external
|
|
20
|
+
event — a human approval, a webhook, a visitor answering a UI prompt — the
|
|
21
|
+
naive implementations are all wrong:
|
|
22
|
+
|
|
23
|
+
- **Block the worker** (`while not done: sleep`, or a Redis `BLPOP`): the worker
|
|
24
|
+
thread/process is pinned for the entire wait. If the external party never
|
|
25
|
+
responds, the resource is held until a timeout — and **nothing survives a
|
|
26
|
+
worker crash**. The in-memory state (the agent's message history, the pending
|
|
27
|
+
tool call) is gone; on restart the step re-runs from scratch and re-prompts.
|
|
28
|
+
- **Lift the wait to the workflow** with `hook()`: correct and durable, but only
|
|
29
|
+
possible when you can restructure the code so the suspend point is at
|
|
30
|
+
workflow level. An LLM agent loop that decides *mid-stream* to call a
|
|
31
|
+
human-in-the-loop tool cannot be lifted — the decision is non-deterministic
|
|
32
|
+
and happens inside the step.
|
|
33
|
+
|
|
34
|
+
`step_hook()` + the step checkpoint API give you durable suspend/resume **without
|
|
35
|
+
leaving the step**. The worker is freed on suspend. On resume the step
|
|
36
|
+
re-executes from the top, but reads its prior state back from a checkpoint
|
|
37
|
+
instead of recomputing it.
|
|
38
|
+
|
|
39
|
+
## The API
|
|
40
|
+
|
|
41
|
+
Three checkpoint calls and one hook call, all imported from `pyworkflow`:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from pyworkflow import (
|
|
45
|
+
step,
|
|
46
|
+
step_hook,
|
|
47
|
+
save_step_checkpoint,
|
|
48
|
+
load_step_checkpoint,
|
|
49
|
+
delete_step_checkpoint,
|
|
50
|
+
STEP_HOOK_TIMEOUT,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@step
|
|
54
|
+
async def review_step(draft: str):
|
|
55
|
+
# 1. On first entry, checkpoint is None. On resume, it holds what we saved.
|
|
56
|
+
checkpoint = await load_step_checkpoint()
|
|
57
|
+
if checkpoint is None:
|
|
58
|
+
state = await expensive_setup(draft) # runs once, ever
|
|
59
|
+
await save_step_checkpoint({"state": state}) # persisted to storage
|
|
60
|
+
else:
|
|
61
|
+
state = checkpoint["state"] # restored on resume
|
|
62
|
+
|
|
63
|
+
# 2. Suspend the step until resume_hook() delivers a payload.
|
|
64
|
+
# on_created fires once, with the token to hand to the external system.
|
|
65
|
+
async def notify(token: str):
|
|
66
|
+
await send_to_reviewer(token)
|
|
67
|
+
|
|
68
|
+
feedback = await step_hook(
|
|
69
|
+
"human_review",
|
|
70
|
+
timeout="24h",
|
|
71
|
+
on_created=notify,
|
|
72
|
+
on_timeout="return", # deadline is durable; see below
|
|
73
|
+
)
|
|
74
|
+
if feedback is STEP_HOOK_TIMEOUT:
|
|
75
|
+
return await finalize_without_review(state)
|
|
76
|
+
|
|
77
|
+
# 3. Continue with restored state + the hook payload.
|
|
78
|
+
result = await finalize(state, feedback)
|
|
79
|
+
await delete_step_checkpoint() # cleanup
|
|
80
|
+
return result
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### How `step_hook()` works (mechanics)
|
|
84
|
+
|
|
85
|
+
Source: `pyworkflow/primitives/step_hook.py`, `pyworkflow/primitives/step_checkpoint.py`.
|
|
86
|
+
|
|
87
|
+
1. **First call** records a `HOOK_CREATED` event, creates a `Hook` row
|
|
88
|
+
(`status=PENDING`, `expires_at` if a timeout was given), fires `on_created(token)`,
|
|
89
|
+
and raises `SuspensionSignal`. The step wrapper
|
|
90
|
+
(`pyworkflow/core/step.py:309`) re-raises it, suspending the workflow and
|
|
91
|
+
**freeing the worker**.
|
|
92
|
+
2. **`resume_hook(token, payload)`** records a `HOOK_RECEIVED` event and
|
|
93
|
+
schedules the run to resume.
|
|
94
|
+
3. **On resume the step re-executes from the top.** `step_hook()` scans the
|
|
95
|
+
event log for its `hook_id`; finding `HOOK_RECEIVED`, it returns the payload
|
|
96
|
+
instead of suspending again. The `hook_id` is deterministic
|
|
97
|
+
(`step_hook_{name}_{counter}` — a per-context counter, `step_hook.py:121-125`),
|
|
98
|
+
so the same call site gets the same id across re-executions.
|
|
99
|
+
4. Everything *before* the `step_hook()` call runs again. That is why state you
|
|
100
|
+
don't want to recompute (LLM turns already taken, expensive setup) must be
|
|
101
|
+
read back from `load_step_checkpoint()`, not recomputed.
|
|
102
|
+
|
|
103
|
+
### The idempotency contract
|
|
104
|
+
|
|
105
|
+
> **A step that uses `step_hook()` MUST be idempotent up to each suspend point.**
|
|
106
|
+
|
|
107
|
+
Re-execution is the mechanism, not a bug. Anything non-deterministic or
|
|
108
|
+
side-effecting that ran before the hook (LLM calls, external writes, streamed
|
|
109
|
+
output) will run *again* on resume unless you gate it on a checkpoint. The
|
|
110
|
+
checkpoint is your "already did this" record. This is the single most important
|
|
111
|
+
thing to get right — see the worked example.
|
|
112
|
+
|
|
113
|
+
### Durable timeouts: `on_timeout="return"`
|
|
114
|
+
|
|
115
|
+
`BLPOP`'s only redeeming feature is its timeout. `step_hook` gives you a
|
|
116
|
+
**durable** one. With `on_timeout="return"` (requires pyworkflow ≥ 0.3.7,
|
|
117
|
+
`#549`), the runtime schedules a resume at `expires_at` and, when the deadline
|
|
118
|
+
passes with no `resume_hook`, the call returns the `STEP_HOOK_TIMEOUT` sentinel
|
|
119
|
+
on re-execution instead of hanging forever. Unlike a `BLPOP` timeout, this
|
|
120
|
+
survives a worker crash — the deadline lives in the event log, not in a parked
|
|
121
|
+
thread.
|
|
122
|
+
|
|
123
|
+
### Where it's wired
|
|
124
|
+
|
|
125
|
+
`set_step_execution_context()` is installed on both execution paths, so
|
|
126
|
+
checkpointing works whether the step runs dispatched or inline:
|
|
127
|
+
|
|
128
|
+
- Celery step worker: `pyworkflow/celery/tasks.py:270`
|
|
129
|
+
- Local runtime: `pyworkflow/core/step.py:277`
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Worked example: refactoring the FlowHunt "hook-as-tool" (issue #5485)
|
|
134
|
+
|
|
135
|
+
This is the concrete migration the rest of this page exists for. It takes the
|
|
136
|
+
`HookToolAdapter` dispatch off its ad-hoc Redis `BLPOP` and onto `step_hook()` +
|
|
137
|
+
`save_step_checkpoint()`.
|
|
138
|
+
|
|
139
|
+
### What exists today (the "random implementation")
|
|
140
|
+
|
|
141
|
+
FlowHunt exposes a flow **hook** component (e.g. `EvaluateClientJs`) to the AI
|
|
142
|
+
agent as a LangChain tool (`HookToolAdapter`). When the agent calls it, today's
|
|
143
|
+
code (in the `flowhunt` repo):
|
|
144
|
+
|
|
145
|
+
| Concern | Current mechanism | File |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| Wait for the visitor | `BLPOP` on a Redis list, on a **64-thread pool** | `flow_engine_v3/internal/tools/hook_tool_adapter.py` (`_HOOK_WAIT_EXECUTOR`, `await_tool_call_result`) |
|
|
148
|
+
| Route the response | `resume_hook` checks a Redis "pending" marker and `PUBLISH`es, **bypassing pyworkflow** | `app/application/flow_session/flow_session_management_service.py` (the `is_pending_tool_call` branch) |
|
|
149
|
+
| Redis plumbing | `register_/is_pending_/publish_/await_/try_pop_/clear_tool_call_result` | `app/infrastructure/flows/repository/runtime_cache_repository.py` |
|
|
150
|
+
|
|
151
|
+
Its own docstring admits the fatal flaw
|
|
152
|
+
(`hook_tool_adapter.py`, "Known limitation (replay)"): *"the dispatch lives only
|
|
153
|
+
in Redis and not in pyworkflow's event log, it is intentionally not durable
|
|
154
|
+
across a worker crash… the step re-executes from the top and the LLM may
|
|
155
|
+
re-emit the tool call, re-prompting the visitor."*
|
|
156
|
+
|
|
157
|
+
**Why it's wrong, precisely:**
|
|
158
|
+
|
|
159
|
+
1. **Resource leak under the normal case.** Every in-flight hook-tool call parks
|
|
160
|
+
a thread on `BLPOP` for up to `hook_timeout_seconds` (default 300s). Visitors
|
|
161
|
+
routinely abandon chats. The pool caps concurrency at 64 abandoned
|
|
162
|
+
conversations before new hook tools cannot dispatch at all.
|
|
163
|
+
2. **No durability.** A worker restart (deploy, OOM, spot reclaim) loses the
|
|
164
|
+
in-memory agent state *and* the pending Redis wait. The run either strands or
|
|
165
|
+
re-prompts the visitor. This is exactly the failure class checkpointing was
|
|
166
|
+
built to eliminate.
|
|
167
|
+
3. **Two protocols through one endpoint.** `resume_hook` has to sniff Redis to
|
|
168
|
+
decide whether an incoming `hook_id` is a real pyworkflow hook or a
|
|
169
|
+
tool-call, because the tool-call path deliberately never entered pyworkflow.
|
|
170
|
+
|
|
171
|
+
### Why you can't just "call `step_hook()` inside the tool"
|
|
172
|
+
|
|
173
|
+
The tempting one-liner — replace the `BLPOP` in `HookToolAdapter._arun` with
|
|
174
|
+
`await step_hook(...)` — **does not work**, for two structural reasons. Both must
|
|
175
|
+
be handled by the refactor:
|
|
176
|
+
|
|
177
|
+
1. **The agent middleware swallows the suspension.** The tool runs inside a
|
|
178
|
+
LangGraph graph built by `create_agent(...)` with
|
|
179
|
+
`ToolErrorIgnoreMiddleware` in the stack
|
|
180
|
+
(`ai_agent/step.py:721-731`). That middleware catches exceptions raised by a
|
|
181
|
+
tool and rewrites them into a `ToolMessage`. A `SuspensionSignal` raised from
|
|
182
|
+
inside the tool would be **caught and turned into a tool error**, never
|
|
183
|
+
reaching `pyworkflow/core/step.py:309`. Suspension has to be raised
|
|
184
|
+
**outside** the graph, at the step boundary.
|
|
185
|
+
2. **`astream` can't be resumed mid-tool without a checkpointer.** The step
|
|
186
|
+
drives the agent with `agent_executor.astream({"messages": messages})`
|
|
187
|
+
(`ai_agent/step.py:774`) and **no LangGraph checkpointer is configured**. On
|
|
188
|
+
step re-execution the graph would re-run from the first message, re-issuing
|
|
189
|
+
every prior (non-deterministic) LLM turn. The transcript must be captured and
|
|
190
|
+
fed back so the graph continues from where it left off.
|
|
191
|
+
|
|
192
|
+
### The pattern: checkpoint the transcript, suspend at the step boundary
|
|
193
|
+
|
|
194
|
+
The suspension lives **inside the step but outside the graph**. The tool's job
|
|
195
|
+
shrinks to "surface a request and the args"; the step owns suspend/resume.
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
astream loop ──emits interrupt──► ai_agent step catches it
|
|
199
|
+
│
|
|
200
|
+
├─ save_step_checkpoint({
|
|
201
|
+
│ "messages": messages_to_dict(transcript),
|
|
202
|
+
│ "pending_tool_call_id": id,
|
|
203
|
+
│ })
|
|
204
|
+
│
|
|
205
|
+
└─ await step_hook(name,
|
|
206
|
+
on_created=emit_to_visitor,
|
|
207
|
+
timeout=..., on_timeout="return")
|
|
208
|
+
│ raises SuspensionSignal
|
|
209
|
+
▼
|
|
210
|
+
workflow suspends, worker freed
|
|
211
|
+
│
|
|
212
|
+
resume_hook(token, payload) ← existing HTTP path
|
|
213
|
+
▼
|
|
214
|
+
step re-executes from top
|
|
215
|
+
├─ load_step_checkpoint() → transcript
|
|
216
|
+
├─ step_hook() returns payload (from event log)
|
|
217
|
+
├─ append ToolMessage(payload, tool_call_id)
|
|
218
|
+
└─ astream({"messages": transcript}) continues
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The last point is the key insight, and it reuses machinery the step **already
|
|
222
|
+
has**: the `GraphRecursionError` handler at `ai_agent/step.py:781-810` already
|
|
223
|
+
performs exactly this kind of transcript surgery — appending synthetic
|
|
224
|
+
`ToolMessage`s to satisfy `tool_call`/`tool_result` pairing and re-invoking.
|
|
225
|
+
Seeding `astream` with a transcript whose last AI `tool_calls` are already
|
|
226
|
+
answered by `ToolMessage`s makes LangGraph continue from the *next* agent node —
|
|
227
|
+
**it does not re-call the LLM for the turn that's already answered.** That is
|
|
228
|
+
what makes the replay deterministic without a LangGraph checkpointer.
|
|
229
|
+
|
|
230
|
+
### Concrete change list
|
|
231
|
+
|
|
232
|
+
**A. Surface the request out of the graph instead of blocking.**
|
|
233
|
+
In `HookToolAdapter._arun` (`hook_tool_adapter.py`), stop calling
|
|
234
|
+
`register_pending_tool_call` / `await_tool_call_result`. Instead the tool emits
|
|
235
|
+
its request (the existing `emit_tool_request`) and hands control back to the
|
|
236
|
+
step. Two viable seams:
|
|
237
|
+
|
|
238
|
+
- *Preferred (LangGraph-native):* return a `Command` / raise a whitelisted
|
|
239
|
+
`HookInterrupt` that the step catches around the `astream` loop. The runner
|
|
240
|
+
already threads `ToolMessage | Command` through its handlers
|
|
241
|
+
(`agent_runner.py:709-905`), so the type plumbing exists.
|
|
242
|
+
- Whichever seam: it must carry `hook_name`, the tool `args`, and the pending
|
|
243
|
+
`tool_call_id` up to the step.
|
|
244
|
+
|
|
245
|
+
**B. Own suspend/resume in the step.**
|
|
246
|
+
In `ai_agent` (`ai_agent/step.py`), wrap the `astream` loop so that when a hook
|
|
247
|
+
interrupt surfaces:
|
|
248
|
+
1. `save_step_checkpoint({"messages": messages_to_dict(accumulated), "pending_tool_call_id": id, "hook_args": args})`.
|
|
249
|
+
2. `payload = await step_hook(hook_name, on_created=<emit to visitor>, timeout=<hook_timeout>, on_timeout="return")`.
|
|
250
|
+
3. On re-execution, first thing: `checkpoint = await load_step_checkpoint()`;
|
|
251
|
+
if present, rebuild `messages` from it, append
|
|
252
|
+
`ToolMessage(content=<post_hook(payload)>, tool_call_id=…)`, and resume
|
|
253
|
+
`astream` from that transcript. `delete_step_checkpoint()` once the agent
|
|
254
|
+
produces its final answer.
|
|
255
|
+
4. If `payload is STEP_HOOK_TIMEOUT`, inject a "visitor did not respond"
|
|
256
|
+
`ToolMessage` and let the agent finish — the durable equivalent of today's
|
|
257
|
+
`TimeoutError`.
|
|
258
|
+
|
|
259
|
+
`on_created(token)` replaces `emit_tool_request(... request_id ...)`: emit the
|
|
260
|
+
pyworkflow **token** (`run_id:hook_id`) to the widget. Keep the hook's existing
|
|
261
|
+
`post_hook(payload, params, ctx)` call to shape the tool result.
|
|
262
|
+
|
|
263
|
+
**C. Delete the Redis bypass in `resume_hook`.**
|
|
264
|
+
In `flow_session_management_service.py`, remove the entire
|
|
265
|
+
`if runtime_cache.is_pending_tool_call(hook_id): …` branch. With the tool now a
|
|
266
|
+
real `step_hook`, `hook_id` is `step_hook_<name>_<n>` and the **existing**
|
|
267
|
+
`pyworkflow.resume_hook(token, payload_data)` path (already in that method)
|
|
268
|
+
handles it. No sniffing, one protocol. Token formats already match:
|
|
269
|
+
`create_hook_token(run_id, hook_id)` == the `f"{run_id}:{hook_id}"` that method
|
|
270
|
+
already builds.
|
|
271
|
+
|
|
272
|
+
**D. Delete the dead Redis plumbing.**
|
|
273
|
+
Remove from `runtime_cache_repository.py` (and its interface
|
|
274
|
+
`app/domain/flows/runtime_cache/repository.py`):
|
|
275
|
+
`register_pending_tool_call`, `is_pending_tool_call`,
|
|
276
|
+
`unregister_pending_tool_call`, `publish_tool_call_result`,
|
|
277
|
+
`await_tool_call_result`, `try_pop_tool_call_result`,
|
|
278
|
+
`clear_tool_call_result`, and the `_tool_call_pending_key` /
|
|
279
|
+
`_tool_call_result_key` helpers. Remove `_HOOK_WAIT_EXECUTOR` from the adapter.
|
|
280
|
+
(The credits and `pending_ui_outputs` namespaces in that repo are unrelated —
|
|
281
|
+
keep them.)
|
|
282
|
+
|
|
283
|
+
**E. Re-evaluate the force-local variant.**
|
|
284
|
+
`ai_agent_force_local` (`ai_agent/step.py:900`) exists because a *dispatched*
|
|
285
|
+
step that blocks for minutes made suspend/resume racy (#5485). With a real
|
|
286
|
+
`step_hook`, the step no longer blocks — it suspends and frees the worker, which
|
|
287
|
+
is the dispatched-worker case `step_hook` is explicitly wired for
|
|
288
|
+
(`celery/tasks.py:270`). The force-local workaround is likely removable, but
|
|
289
|
+
treat that as a **follow-up to validate**, not an assumption — verify a
|
|
290
|
+
dispatched hook-tool suspends and resumes cleanly before deleting it.
|
|
291
|
+
|
|
292
|
+
### What this buys
|
|
293
|
+
|
|
294
|
+
- No parked threads. A suspended run costs storage, not a worker slot. 64
|
|
295
|
+
abandoned chats is no longer a ceiling.
|
|
296
|
+
- Crash-durable. A deploy or OOM mid-wait resumes from the checkpoint; the
|
|
297
|
+
visitor is **not** re-prompted. The adapter's "Known limitation (replay)"
|
|
298
|
+
disclaimer is deleted, not merely documented.
|
|
299
|
+
- One resume protocol. Design-time hooks and hook-tools both flow through
|
|
300
|
+
`pyworkflow.resume_hook`.
|
|
301
|
+
|
|
302
|
+
### Gotchas specific to this step
|
|
303
|
+
|
|
304
|
+
- **Generator step.** `ai_agent` is `@step(..., is_generator=True)` and streams
|
|
305
|
+
partial output before the hook. Re-executed output must not be re-emitted to
|
|
306
|
+
the visitor — gate already-streamed chunks on the checkpoint, or only stream
|
|
307
|
+
deltas produced *after* the restored transcript.
|
|
308
|
+
- **Credits.** The current adapter validates credits at dispatch
|
|
309
|
+
(`validate_credits_for_step`). Keep that on the first pass only; on
|
|
310
|
+
checkpoint-resume the credit was already charged — don't double-charge.
|
|
311
|
+
- **`messages_to_dict` / `messages_from_dict`.** Use LangChain's serialization
|
|
312
|
+
for the transcript so tool-call ids and content blocks round-trip; ad-hoc
|
|
313
|
+
dict-building drops `tool_calls`.
|
|
314
|
+
- **Idempotency of side-effecting tools.** Any tool that ran *before* the hook
|
|
315
|
+
and had side effects will not re-run (it's in the restored transcript), which
|
|
316
|
+
is correct — but make sure your checkpoint captures the transcript *including*
|
|
317
|
+
those tool results, or they'll re-execute.
|
|
318
|
+
|
|
319
|
+
## See also
|
|
320
|
+
|
|
321
|
+
- [Hooks](/concepts/hooks) — workflow-level `hook()` (lift the wait out of the
|
|
322
|
+
step when you can).
|
|
323
|
+
- [Fault Tolerance](/concepts/fault-tolerance) — how suspended runs recover
|
|
324
|
+
after a worker crash.
|
|
325
|
+
- [Steps](/concepts/steps) — retry semantics and the step lifecycle.
|
|
@@ -7,7 +7,7 @@ packages = [{include = "pyworkflow"}]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "pyworkflow-engine"
|
|
10
|
-
version = "0.3.
|
|
10
|
+
version = "0.3.8"
|
|
11
11
|
description = "A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -37,15 +37,15 @@ dependencies = [
|
|
|
37
37
|
"loguru>=0.7.0",
|
|
38
38
|
|
|
39
39
|
# CLI dependencies
|
|
40
|
-
"click>=8.4.
|
|
40
|
+
"click>=8.4.2",
|
|
41
41
|
"inquirerpy>=0.3.4; python_version < '4.0'",
|
|
42
42
|
|
|
43
43
|
# Utilities
|
|
44
44
|
"httpx>=0.25.0",
|
|
45
45
|
"python-dateutil>=2.8.0",
|
|
46
|
-
"filelock>=3.29.
|
|
46
|
+
"filelock>=3.29.5",
|
|
47
47
|
"pyyaml>=6.0.0",
|
|
48
|
-
"croniter>=2.
|
|
48
|
+
"croniter>=6.2.3",
|
|
49
49
|
]
|
|
50
50
|
|
|
51
51
|
[project.optional-dependencies]
|
|
@@ -64,7 +64,7 @@ postgres = [
|
|
|
64
64
|
|
|
65
65
|
# AWS runtime support
|
|
66
66
|
aws = [
|
|
67
|
-
"aws-durable-execution-sdk-python>=
|
|
67
|
+
"aws-durable-execution-sdk-python>=1.6.0",
|
|
68
68
|
]
|
|
69
69
|
dynamodb = [
|
|
70
70
|
"aiobotocore>=3.7.0",
|
|
@@ -84,14 +84,14 @@ all = [
|
|
|
84
84
|
"aiosqlite>=0.19.0",
|
|
85
85
|
"asyncpg>=0.29.0",
|
|
86
86
|
"aiomysql>=0.2.0",
|
|
87
|
-
"aws-durable-execution-sdk-python>=
|
|
87
|
+
"aws-durable-execution-sdk-python>=1.6.0",
|
|
88
88
|
"cassandra-driver>=3.29.0",
|
|
89
89
|
]
|
|
90
90
|
|
|
91
91
|
# Development dependencies
|
|
92
92
|
dev = [
|
|
93
93
|
# Testing
|
|
94
|
-
"pytest>=
|
|
94
|
+
"pytest>=9.1.1",
|
|
95
95
|
"pytest-asyncio>=0.21.0",
|
|
96
96
|
"pytest-cov>=4.1.0",
|
|
97
97
|
"pytest-celery>=0.0.0",
|
|
@@ -100,7 +100,7 @@ dev = [
|
|
|
100
100
|
|
|
101
101
|
# Code quality
|
|
102
102
|
"black>=26.5.1",
|
|
103
|
-
"ruff>=0.15.
|
|
103
|
+
"ruff>=0.15.20",
|
|
104
104
|
"mypy>=2.1.0",
|
|
105
105
|
"pre-commit>=4.6.0",
|
|
106
106
|
|
|
@@ -45,6 +45,10 @@ from pyworkflow.engine.events import (
|
|
|
45
45
|
create_workflow_started_event,
|
|
46
46
|
create_workflow_suspended_event,
|
|
47
47
|
)
|
|
48
|
+
from pyworkflow.engine.executor import (
|
|
49
|
+
record_step_suspended_if_needed,
|
|
50
|
+
step_suspended_already_recorded,
|
|
51
|
+
)
|
|
48
52
|
from pyworkflow.serialization.decoder import deserialize_args, deserialize_kwargs
|
|
49
53
|
from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
|
|
50
54
|
from pyworkflow.storage.base import StorageBackend
|
|
@@ -267,7 +271,9 @@ def execute_step_task(
|
|
|
267
271
|
set_step_execution_context,
|
|
268
272
|
)
|
|
269
273
|
|
|
270
|
-
step_exec_tokens = set_step_execution_context(
|
|
274
|
+
step_exec_tokens = set_step_execution_context(
|
|
275
|
+
step_exec_key, storage, step_id=step_id, step_name=step_name
|
|
276
|
+
)
|
|
271
277
|
except Exception as e:
|
|
272
278
|
logger.warning(f"Failed to set up step execution context: {e}")
|
|
273
279
|
|
|
@@ -658,6 +664,12 @@ async def _record_step_suspended(
|
|
|
658
664
|
|
|
659
665
|
Does NOT schedule workflow resumption — that happens when resume_hook()
|
|
660
666
|
is called externally.
|
|
667
|
+
|
|
668
|
+
Deduplicated per suspension round: if a STEP_SUSPENDED already exists for
|
|
669
|
+
this step *since its most recent STEP_STARTED* (e.g. the engine recorded it
|
|
670
|
+
inline on a force_local step, or a prior worker attempt recorded it), this is
|
|
671
|
+
a no-op. A step that suspends again on the same step_id in a later round
|
|
672
|
+
still records its own STEP_SUSPENDED.
|
|
661
673
|
"""
|
|
662
674
|
from pyworkflow.engine.events import create_step_suspended_event
|
|
663
675
|
|
|
@@ -665,6 +677,11 @@ async def _record_step_suspended(
|
|
|
665
677
|
if hasattr(storage, "connect"):
|
|
666
678
|
await storage.connect()
|
|
667
679
|
|
|
680
|
+
# Short-circuit only if already recorded for the current start (engine inline
|
|
681
|
+
# path or prior attempt) — not for an earlier round on the same step_id.
|
|
682
|
+
if await step_suspended_already_recorded(storage, run_id, step_id):
|
|
683
|
+
return
|
|
684
|
+
|
|
668
685
|
# Wait for WORKFLOW_SUSPENDED event to avoid sequence number race
|
|
669
686
|
max_wait_attempts = 50
|
|
670
687
|
wait_interval = 0.01
|
|
@@ -1132,6 +1149,9 @@ async def _execute_child_workflow_on_worker(
|
|
|
1132
1149
|
)
|
|
1133
1150
|
await storage.record_event(suspended_event)
|
|
1134
1151
|
|
|
1152
|
+
# Record STEP_SUSPENDED for an inline (force_local) step_hook suspension.
|
|
1153
|
+
await record_step_suspended_if_needed(storage, child_run_id, e)
|
|
1154
|
+
|
|
1135
1155
|
logger.debug(
|
|
1136
1156
|
f"Child workflow suspended: {workflow_name}",
|
|
1137
1157
|
parent_run_id=parent_run_id,
|
|
@@ -1532,6 +1552,9 @@ async def _recover_workflow_on_worker(
|
|
|
1532
1552
|
)
|
|
1533
1553
|
await storage.record_event(suspended_event)
|
|
1534
1554
|
|
|
1555
|
+
# Record STEP_SUSPENDED for an inline (force_local) step_hook suspension.
|
|
1556
|
+
await record_step_suspended_if_needed(storage, run_id, e)
|
|
1557
|
+
|
|
1535
1558
|
logger.info(
|
|
1536
1559
|
f"Recovered workflow suspended: {e.reason}",
|
|
1537
1560
|
run_id=run_id,
|
|
@@ -1925,6 +1948,9 @@ async def _start_workflow_on_worker(
|
|
|
1925
1948
|
)
|
|
1926
1949
|
await storage.record_event(suspended_event)
|
|
1927
1950
|
|
|
1951
|
+
# Record STEP_SUSPENDED for an inline (force_local) step_hook suspension.
|
|
1952
|
+
await record_step_suspended_if_needed(storage, run_id, e)
|
|
1953
|
+
|
|
1928
1954
|
logger.info(
|
|
1929
1955
|
f"Workflow suspended on worker: {e.reason}",
|
|
1930
1956
|
run_id=run_id,
|
|
@@ -1959,7 +1985,8 @@ async def _start_workflow_on_worker(
|
|
|
1959
1985
|
# resume_hook() may have recorded HOOK_RECEIVED and scheduled a resume task
|
|
1960
1986
|
# while the workflow was still running (before status was set to SUSPENDED).
|
|
1961
1987
|
# That resume task would have failed try_claim_run and been discarded.
|
|
1962
|
-
|
|
1988
|
+
# Covers both workflow-level hook() ("hook:") and step_hook() suspensions.
|
|
1989
|
+
if hook_id and (e.reason.startswith("hook:") or e.reason.startswith("step_hook:")):
|
|
1963
1990
|
hook_received = await storage.has_event(
|
|
1964
1991
|
run_id, EventType.HOOK_RECEIVED.value, hook_id=hook_id
|
|
1965
1992
|
)
|
|
@@ -2544,6 +2571,9 @@ async def _resume_workflow_on_worker(
|
|
|
2544
2571
|
)
|
|
2545
2572
|
await storage.record_event(suspended_event)
|
|
2546
2573
|
|
|
2574
|
+
# Record STEP_SUSPENDED for an inline (force_local) step_hook suspension.
|
|
2575
|
+
await record_step_suspended_if_needed(storage, run_id, e)
|
|
2576
|
+
|
|
2547
2577
|
logger.info(
|
|
2548
2578
|
f"Workflow suspended again on worker: {e.reason}",
|
|
2549
2579
|
run_id=run_id,
|
|
@@ -2576,8 +2606,9 @@ async def _resume_workflow_on_worker(
|
|
|
2576
2606
|
)
|
|
2577
2607
|
return None
|
|
2578
2608
|
|
|
2579
|
-
# For hook suspensions, check if hook was already received (race condition)
|
|
2580
|
-
|
|
2609
|
+
# For hook suspensions, check if hook was already received (race condition).
|
|
2610
|
+
# Covers both workflow-level hook() ("hook:") and step_hook() suspensions.
|
|
2611
|
+
if hook_id and (e.reason.startswith("hook:") or e.reason.startswith("step_hook:")):
|
|
2581
2612
|
hook_received = await storage.has_event(
|
|
2582
2613
|
run_id, EventType.HOOK_RECEIVED.value, hook_id=hook_id
|
|
2583
2614
|
)
|
|
@@ -136,6 +136,8 @@ class WorkflowContext(ABC):
|
|
|
136
136
|
self._tracing: dict[str, Any] | None = None
|
|
137
137
|
self._tracing_provider: Any = None # TracingProvider instance
|
|
138
138
|
self._trace_id: str | None = None # Stable trace ID
|
|
139
|
+
# Monotonic counter backing deterministic step_hook() ids within this run.
|
|
140
|
+
self._step_hook_counter: int = 0
|
|
139
141
|
|
|
140
142
|
@property
|
|
141
143
|
def run_id(self) -> str:
|
|
@@ -161,6 +163,39 @@ class WorkflowContext(ABC):
|
|
|
161
163
|
"""Get the active TracingProvider instance, or None."""
|
|
162
164
|
return self._tracing_provider
|
|
163
165
|
|
|
166
|
+
# =========================================================================
|
|
167
|
+
# Step-hook counter (public API for cross-process checkpoint/restore)
|
|
168
|
+
# =========================================================================
|
|
169
|
+
|
|
170
|
+
def get_step_hook_counter(self) -> int:
|
|
171
|
+
"""
|
|
172
|
+
Get the current step_hook() counter for this run.
|
|
173
|
+
|
|
174
|
+
step_hook() derives deterministic hook ids as
|
|
175
|
+
``f"step_hook_{name}_{counter}"`` from a monotonic per-run counter.
|
|
176
|
+
Apps that checkpoint a step and restore it in a fresh process (so the
|
|
177
|
+
step body re-executes from the top) must restore this counter before
|
|
178
|
+
re-execution so the same step_hook() call reproduces the same hook id
|
|
179
|
+
and matches the recorded HOOK_CREATED/HOOK_RECEIVED events.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The number of step_hook() calls made so far in this run.
|
|
183
|
+
"""
|
|
184
|
+
return getattr(self, "_step_hook_counter", 0)
|
|
185
|
+
|
|
186
|
+
def set_step_hook_counter(self, value: int) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Set the step_hook() counter for this run.
|
|
189
|
+
|
|
190
|
+
Use together with :meth:`get_step_hook_counter` to restore the counter
|
|
191
|
+
when re-executing a checkpointed step across a process boundary. See
|
|
192
|
+
:meth:`get_step_hook_counter` for the full rationale.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
value: The counter value to restore.
|
|
196
|
+
"""
|
|
197
|
+
self._step_hook_counter = value
|
|
198
|
+
|
|
164
199
|
# =========================================================================
|
|
165
200
|
# Abstract methods - must be implemented by subclasses
|
|
166
201
|
# =========================================================================
|
|
@@ -274,7 +274,9 @@ def step(
|
|
|
274
274
|
|
|
275
275
|
# Set up step execution context for checkpoint/hook primitives
|
|
276
276
|
step_exec_key = f"{ctx.run_id}:{step_id}"
|
|
277
|
-
step_exec_tokens = set_step_execution_context(
|
|
277
|
+
step_exec_tokens = set_step_execution_context(
|
|
278
|
+
step_exec_key, ctx.storage, step_id=step_id, step_name=step_name
|
|
279
|
+
)
|
|
278
280
|
|
|
279
281
|
try:
|
|
280
282
|
# Execute step function
|