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.
Files changed (245) hide show
  1. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/PKG-INFO +8 -8
  2. pyworkflow_engine-0.3.8/docs/concepts/step-hooks-and-checkpointing.mdx +325 -0
  3. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyproject.toml +8 -8
  4. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/__init__.py +1 -1
  5. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/tasks.py +35 -4
  6. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/base.py +35 -0
  7. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/step.py +3 -1
  8. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/executor.py +91 -0
  9. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/step_checkpoint.py +27 -2
  10. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/step_hook.py +19 -3
  11. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/local.py +48 -0
  12. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/memory.py +5 -2
  13. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow_engine.egg-info/SOURCES.txt +2 -0
  14. pyworkflow_engine-0.3.8/tests/integration/test_step_hook_inline_suspension_e2e.py +285 -0
  15. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step_hook.py +363 -0
  16. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/CLAUDE.md +0 -0
  17. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/DISTRIBUTED.md +0 -0
  18. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/LICENSE +0 -0
  19. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/MANIFEST.in +0 -0
  20. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/README.md +0 -0
  21. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/RELEASING.md +0 -0
  22. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/architecture.md +0 -0
  23. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/cancellation.mdx +0 -0
  24. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/continue-as-new.mdx +0 -0
  25. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/events.mdx +0 -0
  26. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/fault-tolerance.mdx +0 -0
  27. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/hooks.mdx +0 -0
  28. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/limitations.mdx +0 -0
  29. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/schedules.mdx +0 -0
  30. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/sleep.mdx +0 -0
  31. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/step-context.mdx +0 -0
  32. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/steps.mdx +0 -0
  33. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/stream-steps.mdx +0 -0
  34. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/stream-workflows.mdx +0 -0
  35. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/concepts/workflows.mdx +0 -0
  36. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/conventions.md +0 -0
  37. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/guides/brokers.mdx +0 -0
  38. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/guides/cli.mdx +0 -0
  39. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/guides/configuration.mdx +0 -0
  40. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/harness-gaps.md +0 -0
  41. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/introduction.mdx +0 -0
  42. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/layers.md +0 -0
  43. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/docs/quickstart.mdx +0 -0
  44. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/__init__.py +0 -0
  45. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/__init__.py +0 -0
  46. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/docker-compose.yml +0 -0
  47. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/pyworkflow.config.yaml +0 -0
  48. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/__init__.py +0 -0
  49. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/basic.py +0 -0
  50. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/batch_processing.py +0 -0
  51. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/cancellation.py +0 -0
  52. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
  53. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
  54. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/child_workflows.py +0 -0
  55. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/continue_as_new.py +0 -0
  56. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
  57. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/hooks.py +0 -0
  58. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/idempotency.py +0 -0
  59. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/long_running.py +0 -0
  60. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/retries.py +0 -0
  61. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/schedules.py +0 -0
  62. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
  63. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/durable/workflows/step_context.py +0 -0
  64. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/01_basic_workflow.py +0 -0
  65. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/02_fault_tolerance.py +0 -0
  66. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/__init__.py +0 -0
  67. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/celery/transient/pyworkflow.config.yaml +0 -0
  68. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/__init__.py +0 -0
  69. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/01_basic_workflow.py +0 -0
  70. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/02_file_storage.py +0 -0
  71. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/03_retries.py +0 -0
  72. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/04_long_running.py +0 -0
  73. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/05_event_log.py +0 -0
  74. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/06_idempotency.py +0 -0
  75. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/07_hooks.py +0 -0
  76. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/08_cancellation.py +0 -0
  77. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/09_child_workflows.py +0 -0
  78. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/10_child_workflow_patterns.py +0 -0
  79. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/11_continue_as_new.py +0 -0
  80. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/12_schedules.py +0 -0
  81. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/13_step_context.py +0 -0
  82. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/14_child_workflow_from_step.py +0 -0
  83. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/durable/__init__.py +0 -0
  84. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/01_quick_tasks.py +0 -0
  85. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/02_retries.py +0 -0
  86. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/03_sleep.py +0 -0
  87. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/examples/local/transient/__init__.py +0 -0
  88. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/__init__.py +0 -0
  89. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/context.py +0 -0
  90. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/handler.py +0 -0
  91. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/aws/testing.py +0 -0
  92. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/__init__.py +0 -0
  93. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/app.py +0 -0
  94. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/loop.py +0 -0
  95. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/reschedule.py +0 -0
  96. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/scheduler.py +0 -0
  97. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/celery/singleton.py +0 -0
  98. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/__init__.py +0 -0
  99. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/__main__.py +0 -0
  100. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/__init__.py +0 -0
  101. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/hooks.py +0 -0
  102. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/quickstart.py +0 -0
  103. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/runs.py +0 -0
  104. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/scheduler.py +0 -0
  105. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/schedules.py +0 -0
  106. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/setup.py +0 -0
  107. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/worker.py +0 -0
  108. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/commands/workflows.py +0 -0
  109. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/output/__init__.py +0 -0
  110. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/output/formatters.py +0 -0
  111. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/output/styles.py +0 -0
  112. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/__init__.py +0 -0
  113. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/async_helpers.py +0 -0
  114. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/config.py +0 -0
  115. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/config_generator.py +0 -0
  116. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/discovery.py +0 -0
  117. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/docker_manager.py +0 -0
  118. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/interactive.py +0 -0
  119. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/cli/utils/storage.py +0 -0
  120. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/config.py +0 -0
  121. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/__init__.py +0 -0
  122. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/aws.py +0 -0
  123. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/local.py +0 -0
  124. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/mock.py +0 -0
  125. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/context/step_context.py +0 -0
  126. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/__init__.py +0 -0
  127. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/exceptions.py +0 -0
  128. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/registry.py +0 -0
  129. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/scheduled.py +0 -0
  130. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/validation.py +0 -0
  131. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/core/workflow.py +0 -0
  132. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/discovery.py +0 -0
  133. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/__init__.py +0 -0
  134. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/events.py +0 -0
  135. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/engine/replay.py +0 -0
  136. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/observability/__init__.py +0 -0
  137. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/observability/logging.py +0 -0
  138. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/__init__.py +0 -0
  139. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/child_handle.py +0 -0
  140. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/child_workflow.py +0 -0
  141. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/continue_as_new.py +0 -0
  142. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/define_hook.py +0 -0
  143. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/hooks.py +0 -0
  144. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/resume_hook.py +0 -0
  145. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/schedule.py +0 -0
  146. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/shield.py +0 -0
  147. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/primitives/sleep.py +0 -0
  148. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/__init__.py +0 -0
  149. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/base.py +0 -0
  150. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/celery.py +0 -0
  151. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/runtime/factory.py +0 -0
  152. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/scheduler/__init__.py +0 -0
  153. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/scheduler/local.py +0 -0
  154. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/serialization/__init__.py +0 -0
  155. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/serialization/decoder.py +0 -0
  156. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/serialization/encoder.py +0 -0
  157. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/__init__.py +0 -0
  158. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/base.py +0 -0
  159. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/cassandra.py +0 -0
  160. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/citus.py +0 -0
  161. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/config.py +0 -0
  162. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/dynamodb.py +0 -0
  163. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/file.py +0 -0
  164. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/migrations/__init__.py +0 -0
  165. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/migrations/base.py +0 -0
  166. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/mysql.py +0 -0
  167. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/postgres.py +0 -0
  168. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/schemas.py +0 -0
  169. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/storage/sqlite.py +0 -0
  170. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/__init__.py +0 -0
  171. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/checkpoint.py +0 -0
  172. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/consumer.py +0 -0
  173. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/context.py +0 -0
  174. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/decorator.py +0 -0
  175. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/dispatcher.py +0 -0
  176. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/emit.py +0 -0
  177. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/registry.py +0 -0
  178. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/runtime.py +0 -0
  179. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/signal.py +0 -0
  180. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/streams/step_context.py +0 -0
  181. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/__init__.py +0 -0
  182. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/base.py +0 -0
  183. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/factory.py +0 -0
  184. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/langfuse.py +0 -0
  185. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/tracing/types.py +0 -0
  186. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/__init__.py +0 -0
  187. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/duration.py +0 -0
  188. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/helpers.py +0 -0
  189. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/pyworkflow/utils/schedule.py +0 -0
  190. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/setup.cfg +0 -0
  191. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/__init__.py +0 -0
  192. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/_reschedule_e2e_app.py +0 -0
  193. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_cancellation.py +0 -0
  194. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_cassandra_storage.py +0 -0
  195. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_child_workflows.py +0 -0
  196. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_continue_as_new.py +0 -0
  197. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_dynamodb_storage.py +0 -0
  198. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_fault_tolerance.py +0 -0
  199. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_list_runs_pagination.py +0 -0
  200. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_reschedule.py +0 -0
  201. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_reschedule_e2e.py +0 -0
  202. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_schedule_storage.py +0 -0
  203. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_schema_migrations.py +0 -0
  204. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_singleton.py +0 -0
  205. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_stream_e2e.py +0 -0
  206. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/integration/test_workflow_suspended.py +0 -0
  207. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/__init__.py +0 -0
  208. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/__init__.py +0 -0
  209. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_cassandra_storage.py +0 -0
  210. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_citus_storage.py +0 -0
  211. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_dynamodb_storage.py +0 -0
  212. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_postgres_storage.py +0 -0
  213. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/backends/test_sqlite_storage.py +0 -0
  214. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/conftest.py +0 -0
  215. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/storage/__init__.py +0 -0
  216. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/storage/test_migrations.py +0 -0
  217. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_cancellation.py +0 -0
  218. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_child_workflows.py +0 -0
  219. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_cli_worker.py +0 -0
  220. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_continue_as_new.py +0 -0
  221. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_emit.py +0 -0
  222. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_event_limits.py +0 -0
  223. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_executor.py +0 -0
  224. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_fault_tolerance.py +0 -0
  225. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_force_local.py +0 -0
  226. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_hooks.py +0 -0
  227. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_parent_run_id.py +0 -0
  228. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_primitives_from_steps.py +0 -0
  229. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_registry.py +0 -0
  230. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_replay.py +0 -0
  231. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_reschedule.py +0 -0
  232. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_retention.py +0 -0
  233. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_schedule_schemas.py +0 -0
  234. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_schedule_utils.py +0 -0
  235. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_scheduled_workflow.py +0 -0
  236. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_signal.py +0 -0
  237. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_singleton.py +0 -0
  238. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step.py +0 -0
  239. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step_checkpoint.py +0 -0
  240. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_step_context.py +0 -0
  241. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_stream_storage.py +0 -0
  242. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_stream_workflow.py +0 -0
  243. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_validation.py +0 -0
  244. {pyworkflow_engine-0.3.7 → pyworkflow_engine-0.3.8}/tests/unit/test_workflow.py +0 -0
  245. {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.7
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.1
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.3
33
+ Requires-Dist: filelock>=3.29.5
34
34
  Requires-Dist: pyyaml>=6.0.0
35
- Requires-Dist: croniter>=2.0.0
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>=0.1.0; extra == "aws"
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>=0.1.0; extra == "all"
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>=7.4.0; extra == "dev"
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.17; extra == "dev"
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.7"
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.1",
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.3",
46
+ "filelock>=3.29.5",
47
47
  "pyyaml>=6.0.0",
48
- "croniter>=2.0.0",
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>=0.1.0",
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>=0.1.0",
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>=7.4.0",
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.17",
103
+ "ruff>=0.15.20",
104
104
  "mypy>=2.1.0",
105
105
  "pre-commit>=4.6.0",
106
106
 
@@ -29,7 +29,7 @@ Quick Start:
29
29
  >>> run_id = await start(my_workflow, "Alice")
30
30
  """
31
31
 
32
- __version__ = "0.3.7"
32
+ __version__ = "0.3.8"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
@@ -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(step_exec_key, storage)
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
- if hook_id and e.reason.startswith("hook:"):
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
- if hook_id and e.reason.startswith("hook:"):
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(step_exec_key, ctx.storage)
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