digitalkin 1.0.0.dev18__tar.gz → 1.0.0.dev20__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 (231) hide show
  1. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/PKG-INFO +1 -1
  2. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/pyproject.toml +1 -1
  3. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/__version__.py +1 -1
  4. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/module_runner.py +12 -1
  5. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/gateway_servicer.py +230 -84
  6. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/utils/circuit_breaker.py +16 -0
  7. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/utils/grpc_client_wrapper.py +62 -53
  8. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/grpc_servers/stream_error_codes.py +1 -0
  9. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/gateway.py +22 -1
  10. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin.egg-info/PKG-INFO +1 -1
  11. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/LICENSE +0 -0
  12. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/README.md +0 -0
  13. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/__init__.py +0 -0
  14. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/mock/__init__.py +0 -0
  15. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/mock/mock_pb2.py +0 -0
  16. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/mock/mock_pb2_grpc.py +0 -0
  17. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/server_async_insecure.py +0 -0
  18. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/server_async_secure.py +0 -0
  19. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/server_sync_insecure.py +0 -0
  20. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/base_server/server_sync_secure.py +0 -0
  21. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/__init__.py +0 -0
  22. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/echo_module.py +0 -0
  23. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/models/__init__.py +0 -0
  24. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/models/input.py +0 -0
  25. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/models/output.py +0 -0
  26. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/models/secret.py +0 -0
  27. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/models/setup.py +0 -0
  28. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/server.py +0 -0
  29. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/triggers/__init__.py +0 -0
  30. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/bench_module/triggers/message_trigger.py +0 -0
  31. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/modules/__init__.py +0 -0
  32. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/modules/archetype_with_tools_module.py +0 -0
  33. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/modules/cpu_intensive_module.py +0 -0
  34. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/modules/dynamic_setup_module.py +0 -0
  35. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/modules/minimal_llm_module.py +0 -0
  36. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/modules/text_transform_module.py +0 -0
  37. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/monitoring/digitalkin_observability/__init__.py +0 -0
  38. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/monitoring/digitalkin_observability/http_server.py +0 -0
  39. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/monitoring/digitalkin_observability/interceptors.py +0 -0
  40. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/monitoring/digitalkin_observability/metrics.py +0 -0
  41. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/monitoring/digitalkin_observability/prometheus.py +0 -0
  42. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/monitoring/tests/test_metrics.py +0 -0
  43. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/client.py +0 -0
  44. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/echo_module.py +0 -0
  45. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/models/__init__.py +0 -0
  46. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/models/input.py +0 -0
  47. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/models/output.py +0 -0
  48. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/models/secret.py +0 -0
  49. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/models/setup.py +0 -0
  50. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/server.py +0 -0
  51. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/triggers/__init__.py +0 -0
  52. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/redis_demo/triggers/message_trigger.py +0 -0
  53. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/services/filesystem_module.py +0 -0
  54. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/examples/services/storage_module.py +0 -0
  55. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/setup.cfg +0 -0
  56. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/__init__.py +0 -0
  57. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/community/__init__.py +0 -0
  58. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/community/agno/__init__.py +0 -0
  59. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/community/agno/agno_adapter.py +0 -0
  60. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/community/agno/agui_tools.py +0 -0
  61. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/community/agno/hitl.py +0 -0
  62. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/community/agno/models.py +0 -0
  63. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/__init__.py +0 -0
  64. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/common/__init__.py +0 -0
  65. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/common/factories.py +0 -0
  66. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/exceptions.py +0 -0
  67. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/job_manager/__init__.py +0 -0
  68. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/job_manager/base_job_manager.py +0 -0
  69. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/job_manager/single_job_manager.py +0 -0
  70. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/profiling/__init__.py +0 -0
  71. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/profiling/step_timer.py +0 -0
  72. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/profiling/task_profiler.py +0 -0
  73. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/resilience/__init__.py +0 -0
  74. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/resilience/bulkhead.py +0 -0
  75. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/resilience/task_supervisor.py +0 -0
  76. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/__init__.py +0 -0
  77. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/base_task_manager.py +0 -0
  78. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/local_task_manager.py +0 -0
  79. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/redis/__init__.py +0 -0
  80. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/redis/proto_streams.py +0 -0
  81. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/redis/redis_client.py +0 -0
  82. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/redis/redis_idempotency.py +0 -0
  83. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/redis/redis_signal.py +0 -0
  84. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/redis/redis_state.py +0 -0
  85. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/remote_task_manager.py +0 -0
  86. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/task_executor.py +0 -0
  87. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/core/task_manager/task_session.py +0 -0
  88. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/exceptions.py +0 -0
  89. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/__init__.py +0 -0
  90. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/_base_server.py +0 -0
  91. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/exceptions.py +0 -0
  92. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/interceptors/__init__.py +0 -0
  93. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/interceptors/request_ids.py +0 -0
  94. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/m2m_call_registry.py +0 -0
  95. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/module_server.py +0 -0
  96. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/module_servicer.py +0 -0
  97. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/stream_registry.py +0 -0
  98. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/stream_session.py +0 -0
  99. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/utils/__init__.py +0 -0
  100. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/utils/grpc_error_handler.py +0 -0
  101. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/utils/utility_schema_extender.py +0 -0
  102. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/grpc_servers/utils/validators.py +0 -0
  103. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/logger.py +0 -0
  104. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/__init__.py +0 -0
  105. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/agui_mixin.py +0 -0
  106. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/base_mixin.py +0 -0
  107. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/cost_mixin.py +0 -0
  108. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/file_history_mixin.py +0 -0
  109. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/filesystem_mixin.py +0 -0
  110. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/logger_mixin.py +0 -0
  111. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/mixins/storage_mixin.py +0 -0
  112. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/__init__.py +0 -0
  113. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/core/__init__.py +0 -0
  114. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/core/job_manager_models.py +0 -0
  115. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/core/redis.py +0 -0
  116. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/core/task_monitor.py +0 -0
  117. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/events/__init__.py +0 -0
  118. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/events/agent_events.py +0 -0
  119. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/grpc_servers/__init__.py +0 -0
  120. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/grpc_servers/circuit_breaker.py +0 -0
  121. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/grpc_servers/m2m.py +0 -0
  122. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/grpc_servers/models.py +0 -0
  123. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/grpc_servers/types.py +0 -0
  124. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/__init__.py +0 -0
  125. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/ag_ui.py +0 -0
  126. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/base_types.py +0 -0
  127. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/module.py +0 -0
  128. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/module_context.py +0 -0
  129. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/module_types.py +0 -0
  130. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/request_metadata.py +0 -0
  131. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/select_schema.py +0 -0
  132. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/setup_types.py +0 -0
  133. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/tool_cache.py +0 -0
  134. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/tool_reference.py +0 -0
  135. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/module/utility.py +0 -0
  136. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/services/__init__.py +0 -0
  137. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/services/cost.py +0 -0
  138. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/services/registry.py +0 -0
  139. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/services/services.py +0 -0
  140. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/services/storage.py +0 -0
  141. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/__init__.py +0 -0
  142. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/grpc_client.py +0 -0
  143. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/log.py +0 -0
  144. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/module.py +0 -0
  145. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/profiling.py +0 -0
  146. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/queue.py +0 -0
  147. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/redis.py +0 -0
  148. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/resilience.py +0 -0
  149. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/server/__init__.py +0 -0
  150. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/server/channel.py +0 -0
  151. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/server/grpc.py +0 -0
  152. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/server/server.py +0 -0
  153. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/server/servicer.py +0 -0
  154. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/task_manager.py +0 -0
  155. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/utils/__init__.py +0 -0
  156. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/settings/utils/channel.py +0 -0
  157. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/utils/__init__.py +0 -0
  158. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/models/utils/dynamic_schema.py +0 -0
  159. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/__init__.py +0 -0
  160. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/_base_module.py +0 -0
  161. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/archetype_module.py +0 -0
  162. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/tool_module.py +0 -0
  163. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/trigger_handler.py +0 -0
  164. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/triggers/__init__.py +0 -0
  165. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py +0 -0
  166. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/triggers/healthcheck_services_trigger.py +0 -0
  167. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/modules/triggers/healthcheck_status_trigger.py +0 -0
  168. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/py.typed +0 -0
  169. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/__init__.py +0 -0
  170. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/base_strategy.py +0 -0
  171. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/communication/__init__.py +0 -0
  172. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/communication/communication_strategy.py +0 -0
  173. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/communication/default_communication.py +0 -0
  174. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/communication/exceptions.py +0 -0
  175. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/communication/grpc_communication.py +0 -0
  176. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/cost/__init__.py +0 -0
  177. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/cost/cost_strategy.py +0 -0
  178. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/cost/default_cost.py +0 -0
  179. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/cost/exceptions.py +0 -0
  180. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/cost/grpc_cost.py +0 -0
  181. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/filesystem/__init__.py +0 -0
  182. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/filesystem/default_filesystem.py +0 -0
  183. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/filesystem/exceptions.py +0 -0
  184. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/filesystem/filesystem_strategy.py +0 -0
  185. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/filesystem/grpc_filesystem.py +0 -0
  186. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/identity/__init__.py +0 -0
  187. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/identity/default_identity.py +0 -0
  188. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/identity/identity_strategy.py +0 -0
  189. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/registry/__init__.py +0 -0
  190. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/registry/default_registry.py +0 -0
  191. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/registry/exceptions.py +0 -0
  192. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/registry/grpc_registry.py +0 -0
  193. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/registry/registry_models.py +0 -0
  194. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/registry/registry_strategy.py +0 -0
  195. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/services_config.py +0 -0
  196. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/services_models.py +0 -0
  197. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/setup/__init__.py +0 -0
  198. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/setup/default_setup.py +0 -0
  199. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/setup/exceptions.py +0 -0
  200. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/setup/grpc_setup.py +0 -0
  201. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/setup/setup_strategy.py +0 -0
  202. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/storage/__init__.py +0 -0
  203. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/storage/default_storage.py +0 -0
  204. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/storage/exceptions.py +0 -0
  205. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/storage/grpc_storage.py +0 -0
  206. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/storage/storage_strategy.py +0 -0
  207. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/task_manager/__init__.py +0 -0
  208. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/task_manager/default_task_manager.py +0 -0
  209. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/task_manager/exceptions.py +0 -0
  210. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/task_manager/redis_task_manager.py +0 -0
  211. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/task_manager/task_manager_strategy.py +0 -0
  212. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/user_profile/__init__.py +0 -0
  213. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/user_profile/default_user_profile.py +0 -0
  214. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/user_profile/exceptions.py +0 -0
  215. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/user_profile/grpc_user_profile.py +0 -0
  216. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/services/user_profile/user_profile_strategy.py +0 -0
  217. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/__init__.py +0 -0
  218. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/arg_parser.py +0 -0
  219. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/conditional_schema.py +0 -0
  220. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/development_mode_action.py +0 -0
  221. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/dynamic_schema.py +0 -0
  222. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/exceptions.py +0 -0
  223. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/llm_ready_schema.py +0 -0
  224. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/package_discover.py +0 -0
  225. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/proto_utils.py +0 -0
  226. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin/utils/schema_splitter.py +0 -0
  227. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin.egg-info/SOURCES.txt +0 -0
  228. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin.egg-info/dependency_links.txt +0 -0
  229. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin.egg-info/requires.txt +0 -0
  230. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/src/digitalkin.egg-info/top_level.txt +0 -0
  231. {digitalkin-1.0.0.dev18 → digitalkin-1.0.0.dev20}/tests/test_exceptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digitalkin
3
- Version: 1.0.0.dev18
3
+ Version: 1.0.0.dev20
4
4
  Summary: SDK to build kin used in DigitalKin
5
5
  Author-email: "DigitalKin.ai" <contact@digitalkin.ai>
6
6
  License: Attribution-NonCommercial-ShareAlike 4.0 International
@@ -37,7 +37,7 @@
37
37
  "pydantic-settings>=2.14.1",
38
38
  "redis[hiredis]>=7.4.0,<9",
39
39
  ]
40
- version = "1.0.0.dev18"
40
+ version = "1.0.0.dev20"
41
41
 
42
42
  [project.optional-dependencies]
43
43
  performance = [ "uvloop>=0.21" ]
@@ -5,4 +5,4 @@ from importlib.metadata import PackageNotFoundError, version
5
5
  try:
6
6
  __version__ = version("digitalkin")
7
7
  except PackageNotFoundError:
8
- __version__ = "1.0.0.dev18"
8
+ __version__ = "1.0.0.dev20"
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from google.protobuf import json_format, struct_pb2
10
10
  from pydantic import ValidationError
11
+ from redis.exceptions import RedisError
11
12
 
12
13
  from digitalkin.core.exceptions import BackpressureTimeoutError
13
14
  from digitalkin.core.profiling.step_timer import StepTimer
@@ -41,7 +42,7 @@ class ModuleRunner:
41
42
  self._redis_client = redis_client
42
43
  self._servicer = servicer
43
44
 
44
- async def run( # noqa: PLR0914, PLR0915
45
+ async def run( # noqa: C901, PLR0914, PLR0915
45
46
  self,
46
47
  query: struct_pb2.Struct,
47
48
  *,
@@ -215,6 +216,16 @@ class ModuleRunner:
215
216
  except BackpressureTimeoutError as exc:
216
217
  logger.exception("ModuleRunner: backpressure timeout", extra=log_extra)
217
218
  await on_fatal(StreamErrorCode.BACKPRESSURE_TIMEOUT.value, str(exc))
219
+ except RedisError as exc:
220
+ logger.error(
221
+ "[VALIDATE R2] ModuleRunner: Redis unavailable writing output: task_id=%s",
222
+ task_id,
223
+ extra=log_extra,
224
+ ) # TODO(validate): remove after prod validation
225
+ await on_fatal(
226
+ StreamErrorCode.REDIS_UNAVAILABLE.value,
227
+ f"redis unavailable: {type(exc).__name__}: {exc}",
228
+ )
218
229
  except Exception as exc:
219
230
  logger.exception("ModuleRunner: module job failed", extra=log_extra)
220
231
  await on_fatal(
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import contextlib
7
7
  import json
8
+ import random
8
9
  import time
9
10
  from datetime import datetime, timezone
10
11
  from typing import TYPE_CHECKING, Any
@@ -34,7 +35,7 @@ from digitalkin.services.communication.exceptions import InvalidConsumerAddressE
34
35
  from digitalkin.services.communication.grpc_communication import GrpcCommunication
35
36
 
36
37
  if TYPE_CHECKING:
37
- from collections.abc import AsyncGenerator, AsyncIterator
38
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable
38
39
 
39
40
  from digitalkin.core.task_manager.module_runner import ModuleRunner
40
41
  from digitalkin.core.task_manager.redis.redis_client import RedisClient
@@ -208,61 +209,36 @@ class GatewayServicer:
208
209
  return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
209
210
 
210
211
  if self._registry.get(task_id) is not None:
211
- logger.debug("Dedup: session exists, reusing", extra=log_extra)
212
- return gateway_pb2.StartStreamResponse(accepted=True, task_id=task_id)
212
+ logger.info(
213
+ "[VALIDATE RD1] StartStream refused: a dial is already live for task_id=%s",
214
+ task_id,
215
+ extra=log_extra,
216
+ ) # TODO(validate): remove after prod validation
217
+ return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
213
218
  timer.mark("dedup_check")
214
219
 
215
220
  # Durable at-most-once guard: survives session teardown and spans replicas.
216
- # A re-issued StartStream for an already-run task is accepted but does NOT
217
- # re-dial/re-execute the caller resumes the existing output via Stream/from_seq.
218
- claim = await self._idempotency.claim(task_id, SharedRedisListener.PROCESS_ID)
221
+ # Reconnection is server-driven (the live dial-back auto re-dials the same
222
+ # consumer), so a re-issued StartStream for an already-claimed/running task is
223
+ # REFUSED one task_id maps to exactly one dial.
224
+ try:
225
+ claim = await self._idempotency.claim(task_id, SharedRedisListener.PROCESS_ID)
226
+ except RedisError:
227
+ logger.warning(
228
+ "[VALIDATE R2] StartStream claim Redis failure → accepted=False",
229
+ extra=log_extra,
230
+ exc_info=True,
231
+ ) # TODO(validate): remove after prod validation
232
+ return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
219
233
  timer.mark("idempotency_claim")
220
234
  if claim is not ClaimResult.CLAIMED:
221
- # Re-issued StartStream for an already-claimed task. The prior dial-back
222
- # is dead (the dedup branch above returned if its session were still live),
223
- # so re-dial in resume mode to re-attach the restarted consumer from its
224
- # cursor — as long as the durable stream is still retained.
225
- stream_len = await self._redis_client.xlen(f"task:{task_id}:stream")
226
- if stream_len == 0:
227
- logger.info(
228
- "[VALIDATE IDEM1] no live stream to resume (TTL expired) task_id=%s (%s)",
229
- task_id,
230
- claim.name,
231
- extra=log_extra,
232
- ) # TODO(validate): remove after prod validation
233
- return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
234
- # Atomic guard: recheck + register + spawn with no await that yields
235
- # between them, so a concurrent re-poke falls into the dedup branch and acks.
236
- if self._registry.get(task_id) is not None:
237
- logger.debug("resume-dial already scheduled, reusing", extra=log_extra)
238
- return gateway_pb2.StartStreamResponse(accepted=True, task_id=task_id)
239
- session = StreamSession(task_id=task_id)
240
- registered = await self._registry.register(
241
- session,
242
- setup_id=request.setup_id,
243
- mission_id=request.mission_id,
244
- )
245
- if not registered:
246
- logger.warning("Resume-dial rejected (capacity)", extra=log_extra)
247
- return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
248
235
  logger.info(
249
- "[VALIDATE IDEM1] resume-dial scheduled task_id=%s (%s) stream_len=%d",
236
+ "[VALIDATE RD1] StartStream refused: task already claimed/running task_id=%s (%s)",
250
237
  task_id,
251
238
  claim.name,
252
- stream_len,
253
239
  extra=log_extra,
254
240
  ) # TODO(validate): remove after prod validation
255
- self._spawn(
256
- self._dial_consumer(
257
- task_id=task_id,
258
- mission_id=request.mission_id,
259
- setup_id=request.setup_id,
260
- address=client_address,
261
- resume=True,
262
- ),
263
- name=f"dial_consumer_{task_id}",
264
- )
265
- return gateway_pb2.StartStreamResponse(accepted=True, task_id=task_id)
241
+ return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
266
242
 
267
243
  session = StreamSession(task_id=task_id)
268
244
  accepted = await self._registry.register(
@@ -274,7 +250,8 @@ class GatewayServicer:
274
250
  if not accepted:
275
251
  logger.warning("Session rejected (capacity)", extra=log_extra)
276
252
  # Release the claim so this task can be retried once capacity frees up.
277
- await self._idempotency.release(task_id)
253
+ with contextlib.suppress(RedisError):
254
+ await self._idempotency.release(task_id)
278
255
  return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
279
256
 
280
257
  # Seed stream.start so the consumer's first XREAD finds data immediately.
@@ -289,10 +266,22 @@ class GatewayServicer:
289
266
  },
290
267
  })
291
268
  timer.mark("build_start_info")
292
- await self._redis_client.xadd(
293
- f"task:{task_id}:stream",
294
- {"pb": start_info.SerializeToString(), "seq": "0"},
295
- )
269
+ try:
270
+ await self._redis_client.xadd(
271
+ f"task:{task_id}:stream",
272
+ {"pb": start_info.SerializeToString(), "seq": "0"},
273
+ )
274
+ except RedisError:
275
+ logger.warning(
276
+ "[VALIDATE R2] StartStream xadd(stream.start) Redis failure → accepted=False",
277
+ extra=log_extra,
278
+ exc_info=True,
279
+ ) # TODO(validate): remove after prod validation
280
+ # Claimed + registered but the stream couldn't be seeded: undo both so a retry re-runs.
281
+ with contextlib.suppress(RedisError):
282
+ await self._idempotency.release(task_id)
283
+ await self._registry.unregister(task_id)
284
+ return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
296
285
  timer.mark("xadd_stream_start")
297
286
 
298
287
  logger.info("→ Dial-back scheduled to consumer %s", client_address, extra=log_extra)
@@ -424,7 +413,14 @@ class GatewayServicer:
424
413
 
425
414
  # Late client: session finished but data still in Redis.
426
415
  if session is None:
427
- stream_len = await self._redis_client.xlen(f"task:{task_id}:stream")
416
+ try:
417
+ stream_len = await self._redis_client.xlen(f"task:{task_id}:stream")
418
+ except RedisError:
419
+ async for out in self._fatal_close(
420
+ task_id, StreamErrorCode.REDIS_UNAVAILABLE.value, "redis unavailable"
421
+ ):
422
+ yield out
423
+ return
428
424
  if stream_len > 0:
429
425
  async for resp in self._consume_guarded(task_id, from_seq):
430
426
  yield resp
@@ -436,10 +432,17 @@ class GatewayServicer:
436
432
  # First message's data is the query.
437
433
  input_key = f"task:{task_id}:input"
438
434
  if first_msg.data and len(first_msg.data.fields) > 0:
439
- await self._redis_client.xadd(
440
- input_key,
441
- {"pb": first_msg.data.SerializeToString()},
442
- )
435
+ try:
436
+ await self._redis_client.xadd(
437
+ input_key,
438
+ {"pb": first_msg.data.SerializeToString()},
439
+ )
440
+ except RedisError:
441
+ async for out in self._fatal_close(
442
+ task_id, StreamErrorCode.REDIS_UNAVAILABLE.value, "redis unavailable"
443
+ ):
444
+ yield out
445
+ return
443
446
 
444
447
  upstream_task = self._spawn(
445
448
  self._read_peer_upstream(request_iterator, task_id, session),
@@ -703,37 +706,150 @@ class GatewayServicer:
703
706
  )
704
707
  yield self._sentinel(0, task_id, "stream.end")
705
708
  return
709
+ except RedisError:
710
+ logger.warning(
711
+ "[VALIDATE R2] stream read Redis failure — closing stream: task_id=%s",
712
+ task_id,
713
+ ) # TODO(validate): remove after prod validation
714
+ yield self._sentinel(
715
+ 0,
716
+ task_id,
717
+ "stream.error",
718
+ code=StreamErrorCode.REDIS_UNAVAILABLE.value,
719
+ message="redis unavailable during stream read",
720
+ fatal=True,
721
+ )
722
+ yield self._sentinel(0, task_id, "stream.end")
723
+ return
706
724
  yield resp
707
725
 
708
- async def _dial_consumer( # noqa: C901, PLR0912, PLR0914, PLR0915
726
+ async def _dial_consumer( # noqa: C901
709
727
  self,
710
728
  task_id: str,
711
729
  mission_id: str,
712
730
  setup_id: str,
713
731
  address: str,
714
- *,
715
- resume: bool = False,
716
732
  ) -> None:
717
- """Dial the consumer's GatewayService.Stream and run the BiDi.
718
-
719
- Fresh dial: sends ``stream.init``, then on the consumer's first reply
720
- (the query) invokes the ``ModuleRunner`` and drains module outputs.
721
- Resume dial: evicts any stale channel, sends ``stream.resume``, reads
722
- the consumer's cursor from the first reply's ``from_seq``, skips the
723
- runner (module already running/done), and drains from that cursor.
724
- Channel is pooled by ``GrpcClientWrapper``.
733
+ """Dial the consumer's GatewayService.Stream, with server-side auto-reconnect.
734
+
735
+ First attempt is a fresh dial (``stream.init`` consumer query
736
+ ``ModuleRunner`` → drain from seq 0). If the BiDi dies before the stream
737
+ is fully delivered and the module is still producing, re-dial the SAME
738
+ address in resume mode (``stream.resume`` consumer replies with its
739
+ ``from_seq`` drain from that cursor, deduping the Redis queue) with
740
+ jittered backoff until the client returns or the reconnect window
741
+ (``DIGITALKIN_GATEWAY_DIAL_BACK_RECONNECT_WINDOW_S``) elapses. The module
742
+ runs as a separate task, spawned exactly once, so it keeps writing to
743
+ Redis across reconnects.
725
744
 
726
745
  Args:
727
746
  task_id: Task to push.
728
747
  mission_id: Mission ID (logging context).
729
748
  setup_id: Setup ID (logging context).
730
749
  address: ``host:port`` of the consumer's GatewayService.
731
- resume: Re-attach to an existing task's output instead of starting it.
732
750
  """
733
751
  log_extra = {"task_id": task_id, "setup_id": setup_id, "mission_id": mission_id}
734
752
  # Bind IDs so the dial-back BiDi + logs carry them (task-local spawned context).
735
753
  RequestContext.bind(task_id=task_id, setup_id=setup_id, mission_id=mission_id)
736
754
 
755
+ module_spawned = False
756
+
757
+ def _mark_spawned() -> None:
758
+ nonlocal module_spawned
759
+ module_spawned = True
760
+
761
+ reconnect = get_gateway_settings().dial_reconnect
762
+ deadline: float | None = None
763
+ attempt = 0
764
+ resume = False
765
+ try:
766
+ while True:
767
+ disconnected = await self._run_dial_attempt(
768
+ task_id=task_id,
769
+ mission_id=mission_id,
770
+ setup_id=setup_id,
771
+ address=address,
772
+ resume=resume,
773
+ on_runner_spawn=_mark_spawned,
774
+ )
775
+ if not disconnected:
776
+ break
777
+ # Client BiDi died mid-stream. Re-dial only while there is a
778
+ # retained stream to resume and the reboot window hasn't elapsed.
779
+ if not module_spawned:
780
+ break
781
+ session = self._registry.get(task_id)
782
+ if session is None or session._stop_event.is_set(): # noqa: SLF001
783
+ break
784
+ try:
785
+ stream_len = await self._redis_client.xlen(f"task:{task_id}:stream")
786
+ except RedisError:
787
+ break
788
+ if stream_len == 0:
789
+ break
790
+ now = time.monotonic()
791
+ if deadline is None:
792
+ deadline = now + reconnect.window_s
793
+ if now >= deadline:
794
+ logger.info(
795
+ "[VALIDATE RD1] dial-back gave up after %.0fs reconnect window: task_id=%s attempts=%d",
796
+ reconnect.window_s,
797
+ task_id,
798
+ attempt,
799
+ ) # TODO(validate): remove after prod validation
800
+ break
801
+ attempt += 1
802
+ delay = min(random.uniform(reconnect.backoff_base_s, reconnect.backoff_max_s), deadline - now) # noqa: S311
803
+ logger.info(
804
+ "[VALIDATE RD1] dial-back disconnected — re-dial attempt %d in %.1fs (resume): task_id=%s",
805
+ attempt,
806
+ delay,
807
+ task_id,
808
+ ) # TODO(validate): remove after prod validation
809
+ await asyncio.sleep(max(0.0, delay))
810
+ resume = True
811
+ finally:
812
+ # End-of-stream cleanup (once). Output stream is left intact for replay.
813
+ try:
814
+ removed = await self._registry.unregister(task_id)
815
+ if removed is not None:
816
+ await removed.teardown()
817
+ except Exception:
818
+ logger.exception("end-of-stream unregister failed", extra=log_extra)
819
+
820
+ async def _run_dial_attempt( # noqa: C901, PLR0912, PLR0914, PLR0915
821
+ self,
822
+ *,
823
+ task_id: str,
824
+ mission_id: str,
825
+ setup_id: str,
826
+ address: str,
827
+ resume: bool,
828
+ on_runner_spawn: Callable[[], None],
829
+ ) -> bool:
830
+ """Run one dial-back BiDi attempt (fresh or resume).
831
+
832
+ Fresh (``resume=False``): sends ``stream.init``, spawns the
833
+ ``ModuleRunner`` on the consumer's first reply (calling
834
+ ``on_runner_spawn``), drains from seq 0. Resume: sends ``stream.resume``,
835
+ reads the consumer's cursor from the first reply's ``from_seq``, skips
836
+ the runner, drains from that cursor. Releases the channel before
837
+ returning; does NOT unregister the session (the caller owns lifecycle).
838
+
839
+ Args:
840
+ task_id: Task to push.
841
+ mission_id: Mission ID (logging context).
842
+ setup_id: Setup ID (logging context).
843
+ address: ``host:port`` of the consumer's GatewayService.
844
+ resume: Re-attach to an existing task's output instead of starting it.
845
+ on_runner_spawn: Called once, when this attempt spawns the module runner.
846
+
847
+ Returns:
848
+ True if the BiDi died before delivering ``stream.end`` (a re-dial
849
+ candidate); False on clean completion or a terminal failure.
850
+ """
851
+ log_extra = {"task_id": task_id, "setup_id": setup_id, "mission_id": mission_id}
852
+
737
853
  async def _fail(code: str, message: str) -> None:
738
854
  """Emit a fatal to Redis (fresh dial) or log only (resume).
739
855
 
@@ -758,7 +874,7 @@ class GatewayServicer:
758
874
  StreamErrorCode.DIAL_BACK_INTERNAL.value,
759
875
  "gateway has no client_config to dial back with",
760
876
  )
761
- return
877
+ return False
762
878
  # setup_version_id is unused on the dial-back path.
763
879
  comm = GrpcCommunication(
764
880
  mission_id=mission_id,
@@ -780,14 +896,14 @@ class GatewayServicer:
780
896
  StreamErrorCode.DIAL_BACK_UNREACHABLE.value,
781
897
  f"dial-back channel build failed: {exc}",
782
898
  )
783
- return
899
+ return False
784
900
  except OSError as exc:
785
901
  logger.exception("dial_consumer: channel build failed addr=%s", address, extra=log_extra)
786
902
  await _fail(
787
903
  StreamErrorCode.DIAL_BACK_UNREACHABLE.value,
788
904
  f"dial-back channel build failed: {type(exc).__name__}: {exc}",
789
905
  )
790
- return
906
+ return False
791
907
  t_stub = time.perf_counter_ns()
792
908
  logger.info("→ Dial-back channel ready to %s", address, extra=log_extra)
793
909
 
@@ -820,7 +936,7 @@ class GatewayServicer:
820
936
  extra=log_extra,
821
937
  )
822
938
  await release()
823
- return
939
+ return False
824
940
 
825
941
  # Outbound is StreamServer, inbound is StreamClient — both share
826
942
  # field tags so re-wrapping ``_consume_from_redis`` output is a rename.
@@ -835,8 +951,11 @@ class GatewayServicer:
835
951
  outgoing_done = asyncio.Event()
836
952
  # Consumer's resume cursor, captured from the first reply's ``from_seq``.
837
953
  resume_cursor = 0
954
+ # Set once the reader reaches EOS and stream.end is delivered to the consumer.
955
+ delivered_eos = False
838
956
 
839
957
  async def _outgoing() -> AsyncGenerator:
958
+ nonlocal delivered_eos
840
959
  try:
841
960
  yield init_server
842
961
  logger.info(
@@ -857,6 +976,7 @@ class GatewayServicer:
857
976
  try:
858
977
  cli_msg = await asyncio.wait_for(anext(reader_iter), timeout=idle_timeout)
859
978
  except StopAsyncIteration:
979
+ delivered_eos = True
860
980
  break
861
981
  except asyncio.TimeoutError:
862
982
  logger.warning(
@@ -869,6 +989,25 @@ class GatewayServicer:
869
989
  f"dial-back idle timeout: no module output in {idle_timeout:.0f}s",
870
990
  )
871
991
  return
992
+ except RedisError:
993
+ logger.warning(
994
+ "[VALIDATE R2] dial-back stream read Redis failure — closing BiDi: task_id=%s",
995
+ task_id,
996
+ extra=log_extra,
997
+ ) # TODO(validate): remove after prod validation
998
+ for sc in (
999
+ self._sentinel(
1000
+ 0,
1001
+ task_id,
1002
+ "stream.error",
1003
+ code=StreamErrorCode.REDIS_UNAVAILABLE.value,
1004
+ message="redis unavailable during stream read",
1005
+ fatal=True,
1006
+ ),
1007
+ self._sentinel(0, task_id, "stream.end"),
1008
+ ):
1009
+ yield gateway_pb2.StreamServer(task_id=task_id, seq=sc.from_seq, data=sc.data)
1010
+ return
872
1011
  yield gateway_pb2.StreamServer(
873
1012
  task_id=task_id,
874
1013
  seq=cli_msg.from_seq,
@@ -880,6 +1019,7 @@ class GatewayServicer:
880
1019
  async def _runner_fatal(code: str, message: str) -> None:
881
1020
  await self._emit_fatal_to_redis(task_id, code=code, message=message, log_extra=log_extra)
882
1021
 
1022
+ retriable = False
883
1023
  t_pre_stream = time.perf_counter_ns()
884
1024
  logger.info(
885
1025
  "[dial-debug] pre_stream dt_since_ready=%.3fms ch_state=%s",
@@ -953,7 +1093,7 @@ class GatewayServicer:
953
1093
  "gateway has no ModuleRunner configured",
954
1094
  )
955
1095
  output_started.set()
956
- return
1096
+ return False
957
1097
  self._spawn(
958
1098
  self._module_runner.run(
959
1099
  upstream.data,
@@ -964,14 +1104,23 @@ class GatewayServicer:
964
1104
  ),
965
1105
  name=f"module_runner_{task_id}",
966
1106
  )
1107
+ on_runner_spawn()
967
1108
  output_started.set()
968
1109
  first = False
969
1110
  continue
970
1111
  # Follow-up multi-turn input → task's input stream.
971
- await self._redis_client.xadd(
972
- f"task:{task_id}:input",
973
- {"pb": upstream.data.SerializeToString()},
974
- )
1112
+ try:
1113
+ await self._redis_client.xadd(
1114
+ f"task:{task_id}:input",
1115
+ {"pb": upstream.data.SerializeToString()},
1116
+ )
1117
+ except RedisError:
1118
+ logger.warning(
1119
+ "[VALIDATE R2] dropped follow-up input (redis unavailable): task_id=%s data=%s",
1120
+ task_id,
1121
+ str(upstream.data)[:512],
1122
+ extra=log_extra,
1123
+ ) # TODO(validate): remove after prod validation
975
1124
  except grpc.aio.AioRpcError as exc:
976
1125
  code_name = exc.code().name
977
1126
  details = exc.details() or ""
@@ -997,6 +1146,8 @@ class GatewayServicer:
997
1146
  )
998
1147
  # Suppress DIAL_BACK_NO_QUERY in finally — RPC error already emitted.
999
1148
  output_started.set()
1149
+ # Client BiDi died before the stream finished → re-dial candidate.
1150
+ retriable = not delivered_eos
1000
1151
  except _GrpcUsageError:
1001
1152
  t_fail = time.perf_counter_ns()
1002
1153
  logger.warning(
@@ -1013,6 +1164,7 @@ class GatewayServicer:
1013
1164
  "dial-back channel closed before BiDi could start",
1014
1165
  )
1015
1166
  output_started.set()
1167
+ retriable = not delivered_eos
1016
1168
  except (RuntimeError, AssertionError, ValueError):
1017
1169
  logger.exception("dial_consumer unexpected error", extra=log_extra)
1018
1170
  if not output_started.is_set():
@@ -1035,10 +1187,4 @@ class GatewayServicer:
1035
1187
  # Unblock _outgoing if consumer never replied.
1036
1188
  output_started.set()
1037
1189
  await release()
1038
- # End-of-stream cleanup; output stream is left intact for replay.
1039
- try:
1040
- removed = await self._registry.unregister(task_id)
1041
- if removed is not None:
1042
- await removed.teardown()
1043
- except Exception:
1044
- logger.exception("end-of-stream unregister failed", extra=log_extra)
1190
+ return retriable
@@ -134,6 +134,22 @@ class CircuitBreaker:
134
134
  self._failure_count,
135
135
  )
136
136
 
137
+ def release_probe(self) -> bool:
138
+ """Release a half-open probe slot without recording an outcome.
139
+
140
+ Called when an in-flight probe is abandoned (e.g. the caller was
141
+ cancelled) so the breaker cannot wedge with the lock held. A cancelled
142
+ probe is neither success nor failure — the slot simply frees for the
143
+ next caller. No-op unless a probe lock is currently held.
144
+
145
+ Returns:
146
+ True if a held probe lock was released; False if none was held.
147
+ """
148
+ if self._state == CBState.HALF_OPEN and self._half_open_lock:
149
+ self._half_open_lock = False
150
+ return True
151
+ return False
152
+
137
153
  def reset(self) -> None:
138
154
  """Force reset to CLOSED state."""
139
155
  self._state = CBState.CLOSED