durable-workflow 2.12.4__tar.gz → 2.13.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 (173) hide show
  1. {durable_workflow-2.12.4/src/durable_workflow.egg-info → durable_workflow-2.13.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/pyproject.toml +3 -3
  3. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/__init__.py +4 -0
  4. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/history_bundle_verify.py +32 -0
  5. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/worker.py +243 -39
  6. durable_workflow-2.13.1/src/durable_workflow/worker_status.py +385 -0
  7. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/workflow.py +13 -3
  8. {durable_workflow-2.12.4 → durable_workflow-2.13.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  10. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cancellation_remaining_time.py +19 -0
  11. durable_workflow-2.13.1/tests/test_history_bundle_pruned.py +132 -0
  12. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_history_event_contract.py +26 -1
  13. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_replay_clock.py +41 -1
  14. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_replay_regression_corpus.py +2 -0
  15. durable_workflow-2.13.1/tests/test_worker_status.py +574 -0
  16. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/LICENSE +0 -0
  17. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/README.md +0 -0
  18. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/setup.cfg +0 -0
  19. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_activity_lease.py +0 -0
  20. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_activity_process.py +0 -0
  21. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_avro.py +0 -0
  22. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  23. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  24. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  25. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_failure_reporting.py +0 -0
  26. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_metric_routes.py +0 -0
  27. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_poll_transport.py +0 -0
  28. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  29. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_query_task_authority.py +0 -0
  30. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  31. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/activity.py +0 -0
  32. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/auth_composition.py +0 -0
  33. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/cancellation.py +0 -0
  34. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/client.py +0 -0
  35. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/errors.py +0 -0
  36. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/external_storage.py +0 -0
  37. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/external_task_input.py +0 -0
  38. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/external_task_result.py +0 -0
  39. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/interceptors.py +0 -0
  40. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/invocable.py +0 -0
  41. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/metrics.py +0 -0
  42. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/nexus.py +0 -0
  43. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/py.typed +0 -0
  44. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/python_conformance.py +0 -0
  45. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/replay_conformance.py +0 -0
  46. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/replay_verify.py +0 -0
  47. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/retry_policy.py +0 -0
  48. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/serializer.py +0 -0
  49. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/server_compatibility.json +0 -0
  50. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/sync.py +0 -0
  51. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/telemetry.py +0 -0
  52. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/telemetry_carrier.py +0 -0
  53. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/testing.py +0 -0
  54. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/worker_session.py +0 -0
  55. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  56. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  57. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  58. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  59. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  60. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_cancellation_policies.py +0 -0
  61. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_context.py +0 -0
  62. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_heartbeat_authority.py +0 -0
  63. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_lease_authority.py +0 -0
  64. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_lease_renewal.py +0 -0
  65. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_lease_worker.py +0 -0
  66. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_process.py +0 -0
  67. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_activity_retry_info.py +0 -0
  68. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_auth_composition.py +0 -0
  69. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_avro_value_benchmark.py +0 -0
  70. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_avro_value_protocol.py +0 -0
  71. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cancellation_context.py +0 -0
  72. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cancellation_scope_admission.py +0 -0
  73. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cancellation_scope_authoring.py +0 -0
  74. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cancellation_scope_boundary.py +0 -0
  75. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cancellation_scope_opening.py +0 -0
  76. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_child_workflow_policies.py +0 -0
  77. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_ci_checkout.py +0 -0
  78. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_ci_integration_endpoint.py +0 -0
  79. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_client.py +0 -0
  80. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_client_telemetry.py +0 -0
  81. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_client_timeouts.py +0 -0
  82. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_codec_regression_corpus.py +0 -0
  83. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  84. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  85. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  86. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  87. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_condition_replay_drift.py +0 -0
  88. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  89. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cooperative_cancellation.py +0 -0
  90. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cooperative_cancellation_client.py +0 -0
  91. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  92. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_cooperative_remote_worker.py +0 -0
  93. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_docstring_cross_references.py +0 -0
  94. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_durable_selection.py +0 -0
  95. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_empty_callback_failure.py +0 -0
  96. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_errors.py +0 -0
  97. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_external_storage.py +0 -0
  98. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_external_task_input.py +0 -0
  99. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_external_task_result.py +0 -0
  100. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_failure_reporting.py +0 -0
  101. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_generator_lifecycle.py +0 -0
  102. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_golden_history_replay.py +0 -0
  103. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_history_bundle_schema2.py +0 -0
  104. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_history_bundle_verify.py +0 -0
  105. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_invocable.py +0 -0
  106. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_local_failure_reporting.py +0 -0
  107. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_message_streams.py +0 -0
  108. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_metric_routes.py +0 -0
  109. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_metrics.py +0 -0
  110. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_nexus_durable_wait.py +0 -0
  111. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_nexus_namespace.py +0 -0
  112. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_nexus_wire.py +0 -0
  113. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_order_processing_example.py +0 -0
  114. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_parallel_completion_order_replay.py +0 -0
  115. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_parallel_saga_authoring.py +0 -0
  116. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_patch_history.py +0 -0
  117. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_pending_operation_replay.py +0 -0
  118. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_poll_failure_backoff.py +0 -0
  119. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_poll_transport.py +0 -0
  120. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_prepared_local_activity_client.py +0 -0
  121. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_prepared_local_activity_groups.py +0 -0
  122. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_prepared_local_activity_policies.py +0 -0
  123. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_prepared_local_activity_worker.py +0 -0
  124. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_public_boundary_scanner.py +0 -0
  125. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_pypi_project_surface.py +0 -0
  126. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_python_conformance.py +0 -0
  127. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_queries.py +0 -0
  128. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_query_task_authority.py +0 -0
  129. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_query_timeout_retry.py +0 -0
  130. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_readme_quickstart.py +0 -0
  131. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_redrive_replay.py +0 -0
  132. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_registration_ambiguity.py +0 -0
  133. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_release_metadata.py +0 -0
  134. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_replay.py +0 -0
  135. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_replay_conformance.py +0 -0
  136. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_replay_verify.py +0 -0
  137. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_retry_policy.py +0 -0
  138. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  139. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_runtime_external_payload_transport.py +0 -0
  140. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_schedules.py +0 -0
  141. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  142. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_sdk_metrics_contract.py +0 -0
  143. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_serializer.py +0 -0
  144. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_signals.py +0 -0
  145. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_sleep.py +0 -0
  146. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_standalone_activity_client.py +0 -0
  147. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_sticky_execution.py +0 -0
  148. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_storage_admission.py +0 -0
  149. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_sync.py +0 -0
  150. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_sync_activities.py +0 -0
  151. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_telemetry.py +0 -0
  152. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_telemetry_carrier.py +0 -0
  153. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_testing_failures.py +0 -0
  154. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_testing_harness.py +0 -0
  155. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_testing_nexus.py +0 -0
  156. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_threaded_heartbeat_example.py +0 -0
  157. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_tls_trust.py +0 -0
  158. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_transport_diagnostics.py +0 -0
  159. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_update_signal_condition_replay.py +0 -0
  160. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_updates.py +0 -0
  161. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_wait_condition.py +0 -0
  162. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_worker.py +0 -0
  163. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_worker_deregistration.py +0 -0
  164. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_worker_observation.py +0 -0
  165. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_worker_session.py +0 -0
  166. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_worker_telemetry_wire.py +0 -0
  167. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_workflow_entry_arguments.py +0 -0
  168. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  169. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_workflow_handler_validation.py +0 -0
  170. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_workflow_identity.py +0 -0
  171. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_workflow_result_timeout.py +0 -0
  172. {durable_workflow-2.12.4 → durable_workflow-2.13.1}/tests/test_workflow_streams.py +0 -0
  173. {durable_workflow-2.12.4 → durable_workflow-2.13.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.12.4
3
+ Version: 2.13.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.12.4"
7
+ version = "2.13.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"
@@ -79,8 +79,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
79
79
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
80
80
 
81
81
  [tool.durable-workflow]
82
- product-train = "2.12.4"
83
- registry-version = "2.12.4"
82
+ product-train = "2.13.1"
83
+ registry-version = "2.13.1"
84
84
  supported-server-versions = "2.8.0"
85
85
  supported-server-release-baselines = { "2" = "2.8.0", "3" = "3.0.0" }
86
86
  worker-protocol-version = "1.20"
@@ -225,6 +225,7 @@ from .serializer import (
225
225
  )
226
226
  from .worker import Worker
227
227
  from .worker_session import WorkerSession, WorkerSessionOptions
228
+ from .worker_status import WorkerRoleStatus, WorkerState, WorkerStatus
228
229
  from .workflow import (
229
230
  ActivityRetryPolicy,
230
231
  ChildWorkflowRetryPolicy,
@@ -314,6 +315,9 @@ __all__ = [
314
315
  "Worker",
315
316
  "WorkerSession",
316
317
  "WorkerSessionOptions",
318
+ "WorkerRoleStatus",
319
+ "WorkerState",
320
+ "WorkerStatus",
317
321
  "WorkerInterceptor",
318
322
  "WorkflowCommandResult",
319
323
  "WorkflowExecution",
@@ -100,6 +100,7 @@ def verify_bundle(bundle: Any, signing_key: str | None = None) -> dict[str, Any]
100
100
 
101
101
  _check_envelope(bundle, findings)
102
102
  _check_workflow(bundle, findings)
103
+ _check_retained_details(bundle, findings)
103
104
  _check_history_events(bundle, findings)
104
105
  _check_commands(bundle, findings)
105
106
  _check_payload_manifest(bundle, findings)
@@ -342,6 +343,37 @@ def _php_integer_key(key: str) -> bool:
342
343
  return -(2**63) <= int(key) <= 2**63 - 1
343
344
 
344
345
 
346
+ def _check_retained_details(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -> None:
347
+ """Fail a run whose details retention cleanup pruned.
348
+
349
+ Such a bundle has no history to replay or import, whatever its other
350
+ fields say. Bundles without ``workflow.details_state`` (older exports) and
351
+ bundles marked ``retained`` are unaffected.
352
+ """
353
+
354
+ workflow = bundle.get("workflow")
355
+ if not isinstance(workflow, Mapping) or workflow.get("details_state") != "pruned":
356
+ return
357
+
358
+ pruned_at = _string_or_none(workflow.get("details_pruned_at"))
359
+ findings.append(
360
+ _finding(
361
+ "history.details_pruned",
362
+ SEVERITY_ERROR,
363
+ (
364
+ "Retention cleanup pruned this run's details"
365
+ f"{'' if pruned_at is None else ' at ' + pruned_at}"
366
+ "; the bundle has no history to replay or import."
367
+ ),
368
+ {
369
+ "details_pruned_at": pruned_at,
370
+ "history_complete": bundle.get("history_complete"),
371
+ },
372
+ path="workflow.details_state",
373
+ )
374
+ )
375
+
376
+
345
377
  def _check_history_events(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -> None:
346
378
  events = bundle.get("history_events")
347
379
  if not isinstance(events, list):
@@ -22,6 +22,7 @@ import hashlib
22
22
  import inspect
23
23
  import json
24
24
  import logging
25
+ import math
25
26
  import pickle
26
27
  import random
27
28
  import sys
@@ -68,6 +69,7 @@ from .client import (
68
69
  WorkflowExecution,
69
70
  _protocol_version_from_env,
70
71
  _supports_cooperative_cancellation_protocol,
72
+ _worker_poll_http_timeout,
71
73
  )
72
74
  from .errors import (
73
75
  ActivityCancelled,
@@ -108,6 +110,12 @@ from .retry_policy import _worker_storage_admission_stop
108
110
  from .telemetry import ExecutionTelemetry, current_execution_telemetry
109
111
  from .telemetry_carrier import identity as observability_identity
110
112
  from .worker_session import WorkerSession, WorkerSessionOptions
113
+ from .worker_status import (
114
+ DEFAULT_EVENT_LOOP_STALL_SECONDS,
115
+ EVENT_LOOP_TICK_SECONDS,
116
+ WorkerStatus,
117
+ _WorkerStatusTracker,
118
+ )
111
119
  from .workflow import (
112
120
  Command,
113
121
  FailWorkflow,
@@ -233,6 +241,15 @@ def _is_storage_admission_error(error: BaseException) -> bool:
233
241
  )
234
242
 
235
243
 
244
+ def _positive_seconds(value: object) -> bool:
245
+ return (
246
+ isinstance(value, (int, float))
247
+ and not isinstance(value, bool)
248
+ and math.isfinite(value)
249
+ and value > 0
250
+ )
251
+
252
+
236
253
  def _poll_error_summary(error: BaseException) -> str:
237
254
  error_class = type(error).__name__
238
255
  if isinstance(error, Unauthorized) or isinstance(error, ServerError) and error.status == 401:
@@ -1209,6 +1226,8 @@ class Worker:
1209
1226
  self._query_thread: threading.Thread | None = None
1210
1227
  self._query_thread_loop: asyncio.AbstractEventLoop | None = None
1211
1228
  self._query_thread_task: asyncio.Task[None] | None = None
1229
+ self._status = _WorkerStatusTracker()
1230
+ self._status_tick_task: asyncio.Task[None] | None = None
1212
1231
  # In-flight slot accounting feeds the periodic heartbeat so operators
1213
1232
  # see free-slot counts without the worker having to re-derive them at
1214
1233
  # shutdown. A workflow reservation covers the multiplexed long poll,
@@ -1514,6 +1533,7 @@ class Worker:
1514
1533
  advertised = ack.get("heartbeat_interval_seconds")
1515
1534
  if isinstance(advertised, int) and advertised > 0:
1516
1535
  self._heartbeat_interval = float(advertised)
1536
+ self._status.registered()
1517
1537
  log.info("worker %s registered on %s", self.worker_id, self.task_queue)
1518
1538
 
1519
1539
  async def _run_workflow_task(self, task: dict[str, Any]) -> list[dict[str, Any]] | None:
@@ -3793,6 +3813,7 @@ class Worker:
3793
3813
  return
3794
3814
  try:
3795
3815
  poll_start = time.perf_counter()
3816
+ self._status.request_started("workflow_poll")
3796
3817
  task = await self.client.poll_workflow_task(
3797
3818
  worker_id=self.worker_id,
3798
3819
  task_queue=self.task_queue,
@@ -3802,15 +3823,18 @@ class Worker:
3802
3823
  history_page_size=WORKFLOW_HISTORY_PAGE_SIZE,
3803
3824
  )
3804
3825
  except asyncio.CancelledError:
3826
+ self._status.request_abandoned("workflow_poll")
3805
3827
  self._release_workflow_capacity()
3806
3828
  raise
3807
3829
  except Exception as e:
3808
3830
  self._release_workflow_capacity()
3809
3831
  if self._stop.is_set():
3832
+ self._status.request_abandoned("workflow_poll")
3810
3833
  return
3834
+ delay = _poll_capacity_delay(e, "workflow_task", self.task_queue)
3835
+ self._record_poll_status("workflow_poll", e, answered=delay is not None)
3811
3836
  if _is_storage_admission_error(e):
3812
3837
  raise
3813
- delay = _poll_capacity_delay(e, "workflow_task", self.task_queue)
3814
3838
  if delay is not None:
3815
3839
  self._record_poll_metrics("workflow", "backpressure", time.perf_counter() - poll_start)
3816
3840
  log.debug("workflow poll capacity backpressure; retrying in %d s", delay)
@@ -3820,6 +3844,7 @@ class Worker:
3820
3844
  await asyncio.sleep(failures.retry_delay(e, "workflow"))
3821
3845
  continue
3822
3846
  failures.reset()
3847
+ self._status.request_succeeded("workflow_poll")
3823
3848
  if task is None:
3824
3849
  self._release_workflow_capacity()
3825
3850
  self._record_poll_metrics("workflow", "empty", time.perf_counter() - poll_start)
@@ -3905,6 +3930,7 @@ class Worker:
3905
3930
  return
3906
3931
  try:
3907
3932
  poll_start = time.perf_counter()
3933
+ self._status.request_started("activity_poll")
3908
3934
  task = await self.client.poll_activity_task(
3909
3935
  worker_id=self.worker_id,
3910
3936
  task_queue=self.task_queue,
@@ -3912,15 +3938,18 @@ class Worker:
3912
3938
  build_id=self.build_id,
3913
3939
  )
3914
3940
  except asyncio.CancelledError:
3941
+ self._status.request_abandoned("activity_poll")
3915
3942
  self._act_semaphore.release()
3916
3943
  raise
3917
3944
  except Exception as e:
3918
3945
  self._act_semaphore.release()
3919
3946
  if self._stop.is_set():
3947
+ self._status.request_abandoned("activity_poll")
3920
3948
  return
3949
+ delay = _poll_capacity_delay(e, "activity_task", self.task_queue)
3950
+ self._record_poll_status("activity_poll", e, answered=delay is not None)
3921
3951
  if _is_storage_admission_error(e):
3922
3952
  raise
3923
- delay = _poll_capacity_delay(e, "activity_task", self.task_queue)
3924
3953
  if delay is not None:
3925
3954
  self._record_poll_metrics("activity", "backpressure", time.perf_counter() - poll_start)
3926
3955
  log.debug("activity poll capacity backpressure; retrying in %d s", delay)
@@ -3930,6 +3959,7 @@ class Worker:
3930
3959
  await asyncio.sleep(failures.retry_delay(e, "activity"))
3931
3960
  continue
3932
3961
  failures.reset()
3962
+ self._status.request_succeeded("activity_poll")
3933
3963
  if task is None:
3934
3964
  self._act_semaphore.release()
3935
3965
  self._record_poll_metrics("activity", "empty", time.perf_counter() - poll_start)
@@ -3965,20 +3995,26 @@ class Worker:
3965
3995
  ):
3966
3996
  try:
3967
3997
  poll_start = time.perf_counter()
3998
+ self._status.request_started("query_poll")
3968
3999
  task = await client.poll_query_task(
3969
4000
  worker_id=self.worker_id,
3970
4001
  task_queue=self.task_queue,
3971
4002
  timeout=self._poll_timeout,
3972
4003
  build_id=self.build_id,
3973
4004
  )
4005
+ except asyncio.CancelledError:
4006
+ self._status.request_abandoned("query_poll")
4007
+ raise
3974
4008
  except Exception as e:
3975
4009
  if self._stop.is_set() or (
3976
4010
  query_thread_stop is not None and query_thread_stop.is_set()
3977
4011
  ):
4012
+ self._status.request_abandoned("query_poll")
3978
4013
  return
4014
+ delay = _poll_capacity_delay(e, "query_task", self.task_queue)
4015
+ self._record_poll_status("query_poll", e, answered=delay is not None)
3979
4016
  if _is_storage_admission_error(e):
3980
4017
  raise
3981
- delay = _poll_capacity_delay(e, "query_task", self.task_queue)
3982
4018
  if delay is not None:
3983
4019
  self._record_poll_metrics("query", "backpressure", time.perf_counter() - poll_start)
3984
4020
  log.debug("query poll capacity backpressure; retrying in %d s", delay)
@@ -3988,6 +4024,7 @@ class Worker:
3988
4024
  await asyncio.sleep(failures.retry_delay(e, "query"))
3989
4025
  continue
3990
4026
  failures.reset()
4027
+ self._status.request_succeeded("query_poll")
3991
4028
  if task is None:
3992
4029
  self._record_poll_metrics("query", "empty", time.perf_counter() - poll_start)
3993
4030
  if self._stop.is_set() or (
@@ -4231,6 +4268,8 @@ class Worker:
4231
4268
  log.exception("query task poller thread was cancelled unexpectedly")
4232
4269
  except Exception:
4233
4270
  log.exception("query task poller thread stopped unexpectedly")
4271
+ finally:
4272
+ self._status.role_exited("query_poll")
4234
4273
 
4235
4274
  @_with_storage_admission_retries
4236
4275
  async def _query_task_thread_main(self) -> None:
@@ -4274,24 +4313,12 @@ class Worker:
4274
4313
  async def run(self) -> None:
4275
4314
  """Register the worker and poll until `stop()` is called or the task is cancelled."""
4276
4315
  self._begin_run()
4277
- try:
4278
- await self._register()
4279
- finally:
4280
- self._registration_done.set()
4316
+ await self._register_for_run()
4281
4317
 
4282
4318
  try:
4283
4319
  if self._stop.is_set():
4284
4320
  return
4285
- loops = [asyncio.create_task(self._heartbeat_loop())]
4286
- if self.workflows:
4287
- loops.append(asyncio.create_task(self._poll_workflow_tasks()))
4288
- if self.activities:
4289
- loops.append(asyncio.create_task(self._poll_activity_tasks()))
4290
- if self.workflows and self._query_tasks_supported:
4291
- if self._can_clone_client_for_query_tasks():
4292
- self._start_query_task_thread()
4293
- else:
4294
- loops.append(asyncio.create_task(self._poll_query_tasks()))
4321
+ loops = self._start_run_pollers()
4295
4322
  self._poller_tasks.update(loops)
4296
4323
 
4297
4324
  try:
@@ -4302,6 +4329,8 @@ class Worker:
4302
4329
  finally:
4303
4330
  primary_error = sys.exc_info()[1]
4304
4331
  primary_traceback = primary_error.__traceback__ if primary_error is not None else None
4332
+ if isinstance(primary_error, Exception):
4333
+ self._status.failed("runtime")
4305
4334
  try:
4306
4335
  await self.stop()
4307
4336
  except BaseException as shutdown_error:
@@ -4309,6 +4338,35 @@ class Worker:
4309
4338
  raise primary_error.with_traceback(primary_traceback) from shutdown_error
4310
4339
  raise
4311
4340
 
4341
+ async def _register_for_run(self) -> None:
4342
+ try:
4343
+ await self._register()
4344
+ except BaseException as error:
4345
+ if isinstance(error, Exception):
4346
+ self._status.failed("registration")
4347
+ if self._shutdown_task is None:
4348
+ # run() leaves no shutdown behind a failed registration.
4349
+ self._cancel_status_tick()
4350
+ raise
4351
+ finally:
4352
+ self._registration_done.set()
4353
+
4354
+ def _start_run_pollers(self) -> list[asyncio.Task[None]]:
4355
+ roles: list[tuple[str, Coroutine[Any, Any, None] | None]] = [("heartbeat", self._heartbeat_loop())]
4356
+ if self.workflows:
4357
+ roles.append(("workflow_poll", self._poll_workflow_tasks()))
4358
+ if self.activities:
4359
+ roles.append(("activity_poll", self._poll_activity_tasks()))
4360
+ query_thread = False
4361
+ if self.workflows and self._query_tasks_supported:
4362
+ query_thread = self._can_clone_client_for_query_tasks()
4363
+ roles.append(("query_poll", None if query_thread else self._poll_query_tasks()))
4364
+ self._status.roles_started(role for role, _ in roles)
4365
+ loops = [self._start_status_role(role, loop) for role, loop in roles if loop is not None]
4366
+ if query_thread:
4367
+ self._start_query_task_thread()
4368
+ return loops
4369
+
4312
4370
  async def _heartbeat_loop(self) -> None:
4313
4371
  """Periodically refresh the server-side worker registration.
4314
4372
 
@@ -4325,22 +4383,36 @@ class Worker:
4325
4383
  await asyncio.wait_for(self._stop.wait(), timeout=self._heartbeat_interval)
4326
4384
  if self._stop.is_set():
4327
4385
  return
4328
- try:
4329
- ack = await self.client.heartbeat_worker(
4330
- worker_id=self.worker_id,
4331
- task_slots=self._current_task_slots(),
4332
- process_metrics=self._current_process_metrics(),
4333
- )
4334
- except Exception as e:
4335
- if _is_storage_admission_error(e) and not self._stop.is_set():
4336
- raise
4337
- log.warning("worker heartbeat failed: %s", e)
4338
- continue
4386
+ ack = await self._send_heartbeat()
4339
4387
  if isinstance(ack, dict):
4340
4388
  advertised = ack.get("heartbeat_interval_seconds")
4341
4389
  if isinstance(advertised, int) and advertised > 0:
4342
4390
  self._heartbeat_interval = float(advertised)
4343
4391
 
4392
+ async def _send_heartbeat(self) -> Any:
4393
+ """Send one heartbeat; return its acknowledgement, or ``None`` after a logged failure."""
4394
+ self._status.request_started("heartbeat")
4395
+ try:
4396
+ ack = await self.client.heartbeat_worker(
4397
+ worker_id=self.worker_id,
4398
+ task_slots=self._current_task_slots(),
4399
+ process_metrics=self._current_process_metrics(),
4400
+ )
4401
+ except asyncio.CancelledError:
4402
+ self._status.request_abandoned("heartbeat")
4403
+ raise
4404
+ except Exception as e:
4405
+ if self._stop.is_set():
4406
+ self._status.request_abandoned("heartbeat")
4407
+ else:
4408
+ self._status.request_failed("heartbeat", e)
4409
+ if _is_storage_admission_error(e) and not self._stop.is_set():
4410
+ raise
4411
+ log.warning("worker heartbeat failed: %s", e)
4412
+ return None
4413
+ self._status.request_succeeded("heartbeat")
4414
+ return ack
4415
+
4344
4416
  def _current_task_slots(self) -> dict[str, int]:
4345
4417
  return {
4346
4418
  "workflow_available": max(
@@ -4442,20 +4514,12 @@ class Worker:
4442
4514
 
4443
4515
  self._begin_run()
4444
4516
  try:
4445
- try:
4446
- await asyncio.wait_for(self._register(), timeout=timeout)
4447
- finally:
4448
- self._registration_done.set()
4517
+ await self._register_within(timeout)
4449
4518
 
4450
4519
  startup_completed = True
4451
4520
  if self._stop.is_set():
4452
4521
  raise asyncio.CancelledError
4453
- background_tasks.append(asyncio.create_task(self._heartbeat_loop()))
4454
- if self.workflows and self._query_tasks_supported:
4455
- if self._can_clone_client_for_query_tasks():
4456
- self._start_query_task_thread()
4457
- else:
4458
- background_tasks.append(asyncio.create_task(self._poll_query_tasks()))
4522
+ background_tasks.extend(self._start_run_until_background())
4459
4523
  self._poller_tasks.update(background_tasks)
4460
4524
  run_until_loop = asyncio.create_task(
4461
4525
  self._run_until_loop(
@@ -4485,6 +4549,26 @@ class Worker:
4485
4549
  raise primary_error.with_traceback(primary_traceback) from shutdown_error
4486
4550
  raise
4487
4551
 
4552
+ async def _register_within(self, timeout: float) -> None:
4553
+ try:
4554
+ await asyncio.wait_for(self._register(), timeout=timeout)
4555
+ except Exception:
4556
+ self._status.failed("registration")
4557
+ raise
4558
+ finally:
4559
+ self._registration_done.set()
4560
+
4561
+ def _start_run_until_background(self) -> list[asyncio.Task[None]]:
4562
+ query_tasks = bool(self.workflows and self._query_tasks_supported)
4563
+ query_thread = query_tasks and self._can_clone_client_for_query_tasks()
4564
+ self._status.roles_started(("heartbeat", "query_poll") if query_tasks else ("heartbeat",))
4565
+ tasks = [self._start_status_role("heartbeat", self._heartbeat_loop())]
4566
+ if query_thread:
4567
+ self._start_query_task_thread()
4568
+ elif query_tasks:
4569
+ tasks.append(self._start_status_role("query_poll", self._poll_query_tasks()))
4570
+ return tasks
4571
+
4488
4572
  async def _run_until_loop(
4489
4573
  self,
4490
4574
  *,
@@ -4644,10 +4728,113 @@ class Worker:
4644
4728
  if self._stop.is_set():
4645
4729
  raise RuntimeError("worker cannot run after stop() has been called")
4646
4730
  self._run_started = True
4731
+ self._status.run_started()
4732
+ self._status_tick_task = asyncio.get_running_loop().create_task(self._status_tick_loop())
4647
4733
  payload_observers = getattr(self.client, "_workflow_payload_observers", None)
4648
4734
  if isinstance(payload_observers, dict):
4649
4735
  payload_observers[self.worker_id] = self._renew_workflow_payload_lease
4650
4736
 
4737
+ def status(
4738
+ self,
4739
+ *,
4740
+ event_loop_stall_seconds: float = DEFAULT_EVENT_LOOP_STALL_SECONDS,
4741
+ request_overdue_seconds: float | None = None,
4742
+ request_stall_seconds: float | None = None,
4743
+ ) -> WorkerStatus:
4744
+ """Return a read-only readiness and liveness snapshot of this worker.
4745
+
4746
+ The snapshot performs no network I/O, claims no tasks, renews no leases
4747
+ and does not wait on the worker's event loop, so a health endpoint may
4748
+ call it from any thread, including while the event loop is stalled.
4749
+
4750
+ Readiness follows this worker's own registration: it becomes ready
4751
+ once the Server acknowledges the registration, while every configured
4752
+ poller is running, the latest Server exchange of every role succeeded
4753
+ and no outstanding request is older than ``request_overdue_seconds``.
4754
+ A transient Server or network failure makes the worker unready; the
4755
+ next successful exchange of that role restores readiness. Liveness
4756
+ reports local progress: the event loop advanced within
4757
+ ``event_loop_stall_seconds``, no configured poller has exited, and no
4758
+ outstanding Server request is older than ``request_stall_seconds``.
4759
+ Idle long polls, Server outages within the transport retry budget and
4760
+ long-running activity handlers keep the worker live. Draining workers
4761
+ stay live but are not ready; stopped and failed workers are neither.
4762
+
4763
+ Args:
4764
+ event_loop_stall_seconds: Maximum age of the event loop's progress
4765
+ tick before the worker is reported not live.
4766
+ request_overdue_seconds: Maximum age of one outstanding poll or
4767
+ heartbeat before the worker is reported not ready. Defaults to
4768
+ one HTTP attempt deadline: the larger of the client request
4769
+ timeout and the poll's absolute HTTP deadline.
4770
+ request_stall_seconds: Maximum age of one outstanding poll or
4771
+ heartbeat before the worker is reported not live. Defaults to
4772
+ twice the client's complete transport retry budget for one
4773
+ such request.
4774
+
4775
+ Returns:
4776
+ A :class:`~durable_workflow.WorkerStatus` snapshot.
4777
+ """
4778
+ if not _positive_seconds(event_loop_stall_seconds):
4779
+ raise ValueError("event_loop_stall_seconds must be a positive finite number")
4780
+ if request_overdue_seconds is None:
4781
+ request_overdue_seconds = self._request_attempt_deadline_seconds()
4782
+ elif not _positive_seconds(request_overdue_seconds):
4783
+ raise ValueError("request_overdue_seconds must be a positive finite number")
4784
+ if request_stall_seconds is None:
4785
+ request_stall_seconds = self._default_request_stall_seconds()
4786
+ elif not _positive_seconds(request_stall_seconds):
4787
+ raise ValueError("request_stall_seconds must be a positive finite number")
4788
+ namespace = self._registration_namespace if self._registration_worker_id is not None else getattr(
4789
+ self.client, "namespace", None
4790
+ )
4791
+ return self._status.snapshot(
4792
+ worker_id=self.worker_id,
4793
+ namespace=namespace if isinstance(namespace, str) else None,
4794
+ task_queue=self.task_queue,
4795
+ event_loop_stall_seconds=float(event_loop_stall_seconds),
4796
+ request_overdue_seconds=float(request_overdue_seconds),
4797
+ request_stall_seconds=float(request_stall_seconds),
4798
+ )
4799
+
4800
+ def _request_attempt_deadline_seconds(self) -> float:
4801
+ client_timeout = getattr(self.client, "timeout", None)
4802
+ request_timeout = (
4803
+ float(client_timeout)
4804
+ if isinstance(client_timeout, (int, float)) and _positive_seconds(client_timeout)
4805
+ else 60.0
4806
+ )
4807
+ poll_deadline = _worker_poll_http_timeout(self._poll_timeout) or 0.0
4808
+ return max(request_timeout, poll_deadline)
4809
+
4810
+ def _default_request_stall_seconds(self) -> float:
4811
+ # One logical request may retry transport failures; only a request
4812
+ # outliving twice that complete budget indicates a local stall.
4813
+ policy = getattr(self.client, "retry_policy", None)
4814
+ attempts = getattr(policy, "max_attempts", 3)
4815
+ backoff = getattr(policy, "max_backoff_seconds", 5.0)
4816
+ attempts = attempts if isinstance(attempts, int) and attempts >= 1 else 3
4817
+ backoff = float(backoff) if isinstance(backoff, (int, float)) and _positive_seconds(backoff) else 5.0
4818
+ budget = attempts * self._request_attempt_deadline_seconds() + (attempts - 1) * backoff
4819
+ return 2.0 * budget
4820
+
4821
+ async def _status_tick_loop(self) -> None:
4822
+ while True:
4823
+ self._status.loop_tick()
4824
+ await asyncio.sleep(EVENT_LOOP_TICK_SECONDS)
4825
+
4826
+ def _start_status_role(self, role: str, loop: Coroutine[Any, Any, None]) -> asyncio.Task[None]:
4827
+ task = asyncio.create_task(loop)
4828
+ task.add_done_callback(lambda _: self._status.role_exited(role))
4829
+ return task
4830
+
4831
+ def _record_poll_status(self, role: str, error: Exception, *, answered: bool) -> None:
4832
+ # Capacity backpressure is a Server answer for an admitted worker.
4833
+ if answered:
4834
+ self._status.request_succeeded(role)
4835
+ else:
4836
+ self._status.request_failed(role, error)
4837
+
4651
4838
  def worker_session(self, options: WorkerSessionOptions) -> WorkerSession:
4652
4839
  """Return a typed lease handle held by this worker until shutdown."""
4653
4840
  existing = self._worker_sessions.get(options.session_id)
@@ -4679,11 +4866,28 @@ class Worker:
4679
4866
  stays within the original shutdown deadline. Unfenced deregistration
4680
4867
  makes one attempt and does not retry an uncertain outcome.
4681
4868
  """
4869
+ self._status.draining()
4682
4870
  self._stop.set()
4683
4871
  if self._shutdown_task is None:
4684
- self._shutdown_task = asyncio.create_task(self._shutdown())
4872
+ self._shutdown_task = asyncio.create_task(self._shutdown_and_record_status())
4685
4873
  await asyncio.shield(self._shutdown_task)
4686
4874
 
4875
+ async def _shutdown_and_record_status(self) -> None:
4876
+ try:
4877
+ await self._shutdown()
4878
+ except BaseException:
4879
+ self._status.failed("shutdown")
4880
+ raise
4881
+ else:
4882
+ self._status.stopped()
4883
+ finally:
4884
+ self._cancel_status_tick()
4885
+
4886
+ def _cancel_status_tick(self) -> None:
4887
+ tick = self._status_tick_task
4888
+ if tick is not None and not tick.done():
4889
+ tick.cancel()
4890
+
4687
4891
  async def _shutdown(self) -> None:
4688
4892
  if self._run_started and not self._registration_done.is_set():
4689
4893
  await self._registration_done.wait()