durable-workflow 2.6.0rc2__tar.gz → 2.6.1__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 (135) hide show
  1. {durable_workflow-2.6.0rc2/src/durable_workflow.egg-info → durable_workflow-2.6.1}/PKG-INFO +28 -9
  2. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/README.md +27 -8
  3. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/pyproject.toml +5 -5
  4. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/__init__.py +2 -0
  5. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_cooperative_cancellation.py +9 -1
  6. durable_workflow-2.6.1/src/durable_workflow/_query_task_authority.py +153 -0
  7. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/client.py +169 -13
  8. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/errors.py +14 -3
  9. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/replay_conformance.py +35 -16
  10. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/retry_policy.py +22 -1
  11. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/worker.py +167 -18
  12. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/workflow.py +100 -40
  13. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1/src/durable_workflow.egg-info}/PKG-INFO +28 -9
  14. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/SOURCES.txt +5 -0
  15. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_opening.py +2 -2
  16. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_ci_checkout.py +39 -0
  17. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_client.py +23 -9
  18. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_condition_replay_drift.py +4 -2
  19. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_cancellation_client.py +4 -7
  20. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_cancellation_worker.py +139 -4
  21. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_remote_worker.py +3 -3
  22. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_errors.py +5 -3
  23. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_external_storage.py +8 -4
  24. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_nexus_durable_wait.py +91 -6
  25. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_nexus_namespace.py +12 -0
  26. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_patch_history.py +74 -1
  27. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_client.py +2 -2
  28. durable_workflow-2.6.1/tests/test_query_task_authority.py +305 -0
  29. durable_workflow-2.6.1/tests/test_query_timeout_retry.py +73 -0
  30. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_release_metadata.py +2 -2
  31. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay.py +18 -6
  32. durable_workflow-2.6.1/tests/test_replay_clock.py +93 -0
  33. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay_conformance.py +44 -3
  34. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay_regression_corpus.py +128 -2
  35. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_runtime_external_payload_transport.py +204 -2
  36. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_storage_admission.py +16 -6
  37. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_worker.py +164 -35
  38. durable_workflow-2.6.1/tests/test_worker_observation.py +299 -0
  39. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/LICENSE +0 -0
  40. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/setup.cfg +0 -0
  41. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_activity_process.py +0 -0
  42. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_avro.py +0 -0
  43. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  44. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  45. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  46. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  47. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/activity.py +0 -0
  48. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/auth_composition.py +0 -0
  49. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/cancellation.py +0 -0
  50. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/external_storage.py +0 -0
  51. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/external_task_input.py +0 -0
  52. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/external_task_result.py +0 -0
  53. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  54. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/interceptors.py +0 -0
  55. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/invocable.py +0 -0
  56. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/metrics.py +0 -0
  57. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/nexus.py +0 -0
  58. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/py.typed +0 -0
  59. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/python_conformance.py +0 -0
  60. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/replay_verify.py +0 -0
  61. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/serializer.py +0 -0
  62. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/sync.py +0 -0
  63. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/testing.py +0 -0
  64. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/worker_session.py +0 -0
  65. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  66. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  67. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  68. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  69. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  70. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_activity_cancellation_policies.py +0 -0
  71. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_activity_context.py +0 -0
  72. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_activity_process.py +0 -0
  73. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_auth_composition.py +0 -0
  74. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_avro_value_benchmark.py +0 -0
  75. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_avro_value_protocol.py +0 -0
  76. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_context.py +0 -0
  77. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_remaining_time.py +0 -0
  78. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_admission.py +0 -0
  79. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_authoring.py +0 -0
  80. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_boundary.py +0 -0
  81. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_child_workflow_policies.py +0 -0
  82. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_ci_integration_endpoint.py +0 -0
  83. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_client_timeouts.py +0 -0
  84. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_codec_regression_corpus.py +0 -0
  85. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  86. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  87. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  88. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  89. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  90. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_cancellation.py +0 -0
  91. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_docstring_cross_references.py +0 -0
  92. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_durable_selection.py +0 -0
  93. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_external_task_input.py +0 -0
  94. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_external_task_result.py +0 -0
  95. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_golden_history_replay.py +0 -0
  96. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_history_bundle_verify.py +0 -0
  97. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_history_event_contract.py +0 -0
  98. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_invocable.py +0 -0
  99. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_message_streams.py +0 -0
  100. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_metrics.py +0 -0
  101. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_nexus_wire.py +0 -0
  102. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_order_processing_example.py +0 -0
  103. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_parallel_completion_order_replay.py +0 -0
  104. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_parallel_saga_authoring.py +0 -0
  105. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_pending_operation_replay.py +0 -0
  106. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_groups.py +0 -0
  107. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_policies.py +0 -0
  108. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_worker.py +0 -0
  109. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_public_boundary_scanner.py +0 -0
  110. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_pypi_project_surface.py +0 -0
  111. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_python_conformance.py +0 -0
  112. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_queries.py +0 -0
  113. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_readme_quickstart.py +0 -0
  114. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_redrive_replay.py +0 -0
  115. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_registration_ambiguity.py +0 -0
  116. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay_verify.py +0 -0
  117. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_retry_policy.py +0 -0
  118. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  119. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_schedules.py +0 -0
  120. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  121. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_serializer.py +0 -0
  122. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_signals.py +0 -0
  123. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_sleep.py +0 -0
  124. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_standalone_activity_client.py +0 -0
  125. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_sticky_execution.py +0 -0
  126. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_sync.py +0 -0
  127. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_testing_harness.py +0 -0
  128. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_update_signal_condition_replay.py +0 -0
  129. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_updates.py +0 -0
  130. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_wait_condition.py +0 -0
  131. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_worker_session.py +0 -0
  132. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  133. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_workflow_result_timeout.py +0 -0
  134. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_workflow_streams.py +0 -0
  135. {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.6.0rc2
3
+ Version: 2.6.1
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -40,6 +40,7 @@ Dynamic: license-file
40
40
 
41
41
  # Durable Workflow Python SDK
42
42
 
43
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=durable-workflow_sdk-python&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=durable-workflow_sdk-python)
43
44
  [![CI](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml)
44
45
  [![PyPI](https://img.shields.io/pypi/v/durable-workflow.svg)](https://pypi.org/project/durable-workflow/)
45
46
  [![Python](https://img.shields.io/pypi/pyversions/durable-workflow.svg)](https://pypi.org/project/durable-workflow/)
@@ -138,6 +139,7 @@ for the complete cross-SDK contract.
138
139
  - [Python SDK guide](https://durable-workflow.com/docs/2.0/polyglot/python/)
139
140
  - [Complete SDK reference](docs/sdk-reference.md)
140
141
  - [Runnable examples](examples/)
142
+ - [Separate workflow and activity worker processes](docs/guides/separate-workers.md)
141
143
  - [Symmetric SDK playground](https://github.com/durable-workflow/sample-app#symmetric-sdk-playground)
142
144
 
143
145
  ## Runtime choices
@@ -155,16 +157,26 @@ capabilities with Server at startup. Use stable `2.x` SDK and Server channels
155
157
  for new applications. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/)
156
158
  documents protocol and upgrade guarantees.
157
159
 
158
- ## Cooperative cancellation release candidate
160
+ ## Worker protocol and cooperative cancellation
161
+
162
+ Workers use protocol 1.20 by default. For a Server that supports only 1.19, set
163
+ `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.19`. This changes the request protocol
164
+ without enabling optional worker features.
159
165
 
160
166
  Use `request_cancellation()` for bounded, replayable workflow cleanup. Opt in
161
167
  against a Server that advertises protocol 1.20 and the required capabilities:
162
- set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` and include
163
- `cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
168
+ include `cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
164
169
  admission also needs `prepared_local_activities`, with
165
170
  `prepared_local_activity_cancellation_policies` for explicit local policies.
166
171
  Independently cancellable scopes remain disabled.
167
172
 
173
+ For synchronous Nexus completion waits, the cooperative worker also negotiates
174
+ `nexus_cooperative_cancellation` when Server advertises the installed
175
+ `workflow_run` binding. Owned target cleanup keeps the caller pending until the
176
+ Server commits delivery at the original call. Async and acceptance-only calls
177
+ keep their admission behavior. Existing terminal results and recorded legacy
178
+ Nexus histories retain their replay semantics.
179
+
168
180
  The cooperative worker supervises async and synchronous activity callbacks
169
181
  independently of application heartbeats. Existing terminal cancellation remains
170
182
  available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
@@ -172,6 +184,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
172
184
 
173
185
  ## Development
174
186
 
187
+ The installed `durable-workflow-replay-conformance` command runs an offline
188
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
189
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
190
+ The report records that installed identity separately from the declared platform
191
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
192
+ does not prove installation or execution of the other declared artifacts; the
193
+ aggregate runner supplies its own verified installation receipts.
194
+
175
195
  ```bash
176
196
  pip install -e '.[dev]'
177
197
  ruff check src/ tests/
@@ -196,11 +216,10 @@ to true. CI verifies that checkout, builds the candidate Server, enables protoco
196
216
  authority and exact source provenance. An optional exact `native_commit` mounts
197
217
  that public Native checkout read-only into the test stack. The image's published
198
218
  Composer authority stays intact and the evidence identifies the source overlay.
199
- These are source qualification runs. For a local
200
- candidate, set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` before starting
201
- Compose and `DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases
202
- fail if the runtime does not discover the required capability. Ordinary CI
203
- keeps protocol 1.19 and skips this unpublished feature's connected cases.
219
+ These are source qualification runs. For a local candidate, set
220
+ `DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases fail if the
221
+ runtime does not discover the required capability. Ordinary CI uses the SDK's
222
+ default protocol and skips the optional connected cooperative cases.
204
223
 
205
224
  ## License
206
225
 
@@ -1,5 +1,6 @@
1
1
  # Durable Workflow Python SDK
2
2
 
3
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=durable-workflow_sdk-python&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=durable-workflow_sdk-python)
3
4
  [![CI](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml)
4
5
  [![PyPI](https://img.shields.io/pypi/v/durable-workflow.svg)](https://pypi.org/project/durable-workflow/)
5
6
  [![Python](https://img.shields.io/pypi/pyversions/durable-workflow.svg)](https://pypi.org/project/durable-workflow/)
@@ -98,6 +99,7 @@ for the complete cross-SDK contract.
98
99
  - [Python SDK guide](https://durable-workflow.com/docs/2.0/polyglot/python/)
99
100
  - [Complete SDK reference](docs/sdk-reference.md)
100
101
  - [Runnable examples](examples/)
102
+ - [Separate workflow and activity worker processes](docs/guides/separate-workers.md)
101
103
  - [Symmetric SDK playground](https://github.com/durable-workflow/sample-app#symmetric-sdk-playground)
102
104
 
103
105
  ## Runtime choices
@@ -115,16 +117,26 @@ capabilities with Server at startup. Use stable `2.x` SDK and Server channels
115
117
  for new applications. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/)
116
118
  documents protocol and upgrade guarantees.
117
119
 
118
- ## Cooperative cancellation release candidate
120
+ ## Worker protocol and cooperative cancellation
121
+
122
+ Workers use protocol 1.20 by default. For a Server that supports only 1.19, set
123
+ `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.19`. This changes the request protocol
124
+ without enabling optional worker features.
119
125
 
120
126
  Use `request_cancellation()` for bounded, replayable workflow cleanup. Opt in
121
127
  against a Server that advertises protocol 1.20 and the required capabilities:
122
- set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` and include
123
- `cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
128
+ include `cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
124
129
  admission also needs `prepared_local_activities`, with
125
130
  `prepared_local_activity_cancellation_policies` for explicit local policies.
126
131
  Independently cancellable scopes remain disabled.
127
132
 
133
+ For synchronous Nexus completion waits, the cooperative worker also negotiates
134
+ `nexus_cooperative_cancellation` when Server advertises the installed
135
+ `workflow_run` binding. Owned target cleanup keeps the caller pending until the
136
+ Server commits delivery at the original call. Async and acceptance-only calls
137
+ keep their admission behavior. Existing terminal results and recorded legacy
138
+ Nexus histories retain their replay semantics.
139
+
128
140
  The cooperative worker supervises async and synchronous activity callbacks
129
141
  independently of application heartbeats. Existing terminal cancellation remains
130
142
  available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
@@ -132,6 +144,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
132
144
 
133
145
  ## Development
134
146
 
147
+ The installed `durable-workflow-replay-conformance` command runs an offline
148
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
149
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
150
+ The report records that installed identity separately from the declared platform
151
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
152
+ does not prove installation or execution of the other declared artifacts; the
153
+ aggregate runner supplies its own verified installation receipts.
154
+
135
155
  ```bash
136
156
  pip install -e '.[dev]'
137
157
  ruff check src/ tests/
@@ -156,11 +176,10 @@ to true. CI verifies that checkout, builds the candidate Server, enables protoco
156
176
  authority and exact source provenance. An optional exact `native_commit` mounts
157
177
  that public Native checkout read-only into the test stack. The image's published
158
178
  Composer authority stays intact and the evidence identifies the source overlay.
159
- These are source qualification runs. For a local
160
- candidate, set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` before starting
161
- Compose and `DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases
162
- fail if the runtime does not discover the required capability. Ordinary CI
163
- keeps protocol 1.19 and skips this unpublished feature's connected cases.
179
+ These are source qualification runs. For a local candidate, set
180
+ `DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases fail if the
181
+ runtime does not discover the required capability. Ordinary CI uses the SDK's
182
+ default protocol and skips the optional connected cooperative cases.
164
183
 
165
184
  ## License
166
185
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.6.0-rc.2"
7
+ version = "2.6.1"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,10 +71,10 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.6.0-rc.2"
75
- registry-version = "2.6.0rc2"
76
- supported-server-versions = "2.5.16"
77
- worker-protocol-version = "1.19"
74
+ product-train = "2.6.1"
75
+ registry-version = "2.6.1"
76
+ supported-server-versions = "2.8.0"
77
+ worker-protocol-version = "1.20"
78
78
  control-plane-version = "2"
79
79
  durable-selection = true
80
80
  durable-selection-minimum-worker-protocol-version = "1.19"
@@ -88,6 +88,7 @@ from .errors import (
88
88
  NonDeterministicReplayError,
89
89
  NonRetryableError,
90
90
  QueryFailed,
91
+ QueryTaskAuthorityLost,
91
92
  RuntimeCapabilityUnsupported,
92
93
  RuntimeDiscoveryUnavailable,
93
94
  SagaCompensationFailed,
@@ -375,6 +376,7 @@ __all__ = [
375
376
  "InvalidWorkflowDefinition",
376
377
  "NoopMetrics",
377
378
  "QueryFailed",
379
+ "QueryTaskAuthorityLost",
378
380
  "RuntimeCapabilityUnsupported",
379
381
  "RuntimeDiscoveryUnavailable",
380
382
  "RuntimeExternalPayloadReference",
@@ -16,7 +16,10 @@ from .errors import NonDeterministicReplayError
16
16
 
17
17
  REQUEST_EVENT = "CooperativeCancellationRequested"
18
18
  DELIVERY_EVENT = "CooperativeCancellationDelivered"
19
- _CALL_KINDS = {"activity", "local_activity", "timer", "condition", "signal", "child", "parallel", "selection_handle"}
19
+ _CALL_KINDS = {
20
+ "activity", "local_activity", "timer", "condition", "signal", "child", "parallel", "selection_handle",
21
+ "service_operation",
22
+ }
20
23
  _RESOLUTION_EVENTS = {
21
24
  "ActivityCompleted",
22
25
  "ActivityFailed",
@@ -31,6 +34,9 @@ _RESOLUTION_EVENTS = {
31
34
  "ChildRunFailed",
32
35
  "ChildRunCancelled",
33
36
  "ChildRunTerminated",
37
+ "ServiceCallCompleted",
38
+ "ServiceCallFailed",
39
+ "ServiceCallCancelled",
34
40
  }
35
41
 
36
42
 
@@ -265,6 +271,8 @@ def read_cancellation_history(
265
271
  "ChildRunFailed",
266
272
  "ChildRunCancelled",
267
273
  "ChildRunTerminated",
274
+ "ServiceCallFailed",
275
+ "ServiceCallCancelled",
268
276
  }:
269
277
  failed.add(sequence)
270
278
  state = CancellationHistory(
@@ -0,0 +1,153 @@
1
+ """A query delivery's original, non-renewing execution budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import time
8
+ from collections.abc import Awaitable, Callable, Mapping
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from typing import Any, TypeVar
13
+
14
+ from .errors import QueryTaskAuthorityLost
15
+
16
+ _T = TypeVar("_T")
17
+ _UTC6 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\Z")
18
+ _OBSERVATION_INTERVAL_SECONDS = 5.0
19
+
20
+
21
+ def _timestamp(value: Any, *, canonical: bool) -> datetime:
22
+ if not isinstance(value, str) or (canonical and _UTC6.fullmatch(value) is None):
23
+ raise ValueError("missing or malformed query timing")
24
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
25
+ if parsed.tzinfo is None:
26
+ raise ValueError("query timing must include a timezone")
27
+ return parsed
28
+
29
+
30
+ @dataclass
31
+ class QueryTaskAuthority:
32
+ client: object
33
+ namespace: str
34
+ task_id: str
35
+ attempt: int
36
+ owner: str
37
+ observed: bool
38
+ timing: dict[str, str]
39
+ until: float
40
+ identity: dict[str, Any]
41
+ observer: Callable[[], Awaitable[Mapping[str, Any]]] | None = field(default=None, repr=False)
42
+
43
+ @classmethod
44
+ def delivery(cls, client: object, namespace: str, task: Mapping[str, Any], *,
45
+ owner: str, task_queue: str, observed: bool) -> QueryTaskAuthority:
46
+ task_id, attempt = task.get("query_task_id"), task.get("query_task_attempt")
47
+ if (not isinstance(owner, str) or not owner or not isinstance(task_queue, str) or not task_queue
48
+ or not isinstance(task_id, str) or not task_id or type(attempt) is not int or attempt < 1
49
+ or task.get("lease_owner") != owner or task.get("task_queue") != task_queue
50
+ or task.get("namespace", namespace) != namespace):
51
+ raise QueryTaskAuthorityLost(str(task_id or ""), "invalid_query_claim")
52
+ try:
53
+ lease = _timestamp(task.get("lease_expires_at"), canonical=observed)
54
+ timing = {"lease_expires_at": task["lease_expires_at"]}
55
+ bounds = [lease]
56
+ if observed or "query_admitted_at" in task or "query_deadline_at" in task:
57
+ admitted = _timestamp(task.get("query_admitted_at"), canonical=True)
58
+ deadline = _timestamp(task.get("query_deadline_at"), canonical=True)
59
+ if deadline < admitted or (observed and lease > deadline):
60
+ raise ValueError("inconsistent original query timing")
61
+ timing.update({"query_admitted_at": task["query_admitted_at"],
62
+ "query_deadline_at": task["query_deadline_at"]})
63
+ bounds.append(deadline)
64
+ # Legacy compatibility has no non-renewing observation. Its local
65
+ # clock check cannot establish an undisclosed caller deadline.
66
+ reference = (_timestamp(task.get("server_time"), canonical=True) if observed
67
+ else datetime.now(timezone.utc))
68
+ until = time.monotonic() + (min(bounds) - reference).total_seconds()
69
+ except (ValueError, TypeError, OverflowError) as error:
70
+ raise QueryTaskAuthorityLost(task_id, "invalid_query_timing") from error
71
+ result = cls(client, namespace, task_id, attempt, owner, observed, timing, until,
72
+ {key: task.get(key) for key in ("workflow_id", "run_id", "task_queue")})
73
+ result.remaining()
74
+ return result
75
+
76
+ def matches(self, client: object, namespace: str, task: Mapping[str, Any], owner: str) -> bool:
77
+ return (self.client is client and self.namespace == namespace and self.owner == owner
78
+ and task.get("query_task_id") == self.task_id
79
+ and type(task.get("query_task_attempt")) is int and task.get("query_task_attempt") == self.attempt
80
+ and task.get("lease_owner") == owner
81
+ and all(task.get(key) == value for key, value in self.identity.items())
82
+ and all(task.get(key) == value for key, value in self.timing.items()))
83
+
84
+ def remaining(self) -> float:
85
+ remaining = self.until - time.monotonic()
86
+ if remaining <= 0:
87
+ raise QueryTaskAuthorityLost(self.task_id, "query_execution_budget_expired")
88
+ return remaining
89
+
90
+ async def confirm(self) -> None:
91
+ remaining = self.remaining()
92
+ if not self.observed:
93
+ return
94
+ started = time.monotonic()
95
+ try:
96
+ if self.observer is None:
97
+ raise ValueError("query authority observer is unavailable")
98
+ reply = await asyncio.wait_for(self.observer(), timeout=min(5.0, remaining))
99
+ if (not isinstance(reply, Mapping) or reply.get("query_task_id") != self.task_id
100
+ or type(reply.get("query_task_attempt")) is not int
101
+ or reply.get("query_task_attempt") != self.attempt or reply.get("lease_owner") != self.owner
102
+ or any(reply.get(key) != value for key, value in self.timing.items())):
103
+ raise ValueError("query authority observation changed its original claim")
104
+ if (reply.get("can_continue") is not True or reply.get("query_task_status") != "leased"
105
+ or reply.get("reason") is not None):
106
+ raise QueryTaskAuthorityLost(self.task_id, str(reply.get("reason") or "query_claim_unavailable"))
107
+ server_time = _timestamp(reply.get("server_time"), canonical=True)
108
+ bound = min(_timestamp(value, canonical=True) for key, value in self.timing.items()
109
+ if key != "query_admitted_at")
110
+ # Charging the entire observation round trip is conservative. A
111
+ # later observation may shorten this bound but can never reset it.
112
+ self.until = min(self.until, started + (bound - server_time).total_seconds())
113
+ except QueryTaskAuthorityLost:
114
+ raise
115
+ except Exception as error:
116
+ raise QueryTaskAuthorityLost(self.task_id, "query_authority_unavailable") from error
117
+ self.remaining()
118
+
119
+ async def run(self, operation: Callable[[], Awaitable[_T]], *, observe: bool = True,
120
+ confirm_after: bool = True) -> _T:
121
+ await self.confirm()
122
+ execution = asyncio.ensure_future(operation())
123
+
124
+ async def watch() -> None:
125
+ while True:
126
+ await asyncio.sleep(min(_OBSERVATION_INTERVAL_SECONDS, self.remaining()))
127
+ await self.confirm()
128
+
129
+ observation = asyncio.create_task(watch()) if observe and self.observed else None
130
+ pending: set[asyncio.Future[Any]] = {execution}
131
+ if observation is not None:
132
+ pending.add(observation)
133
+ try:
134
+ done, _ = await asyncio.wait(pending, timeout=self.remaining(), return_when=asyncio.FIRST_COMPLETED)
135
+ if not done:
136
+ raise QueryTaskAuthorityLost(self.task_id, "query_execution_budget_expired")
137
+ if observation is not None and observation in done:
138
+ await observation
139
+ raise QueryTaskAuthorityLost(self.task_id, "query_authority_unavailable")
140
+ result = await execution
141
+ if confirm_after:
142
+ await self.confirm()
143
+ return result
144
+ finally:
145
+ for future in pending:
146
+ future.cancel()
147
+ await asyncio.gather(*pending, return_exceptions=True)
148
+
149
+
150
+ # Async task creation copies this context. Each dispatched query keeps the
151
+ # loading budget without adding private fields to the public wire dictionary
152
+ # or retaining an unbounded task-ID registry on Client/Worker.
153
+ current_query_authority: ContextVar[QueryTaskAuthority | None] = ContextVar("query_task_authority", default=None)