flashruntime 0.6.0__tar.gz → 0.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 (194) hide show
  1. {flashruntime-0.6.0/flashruntime.egg-info → flashruntime-0.6.1}/PKG-INFO +1 -1
  2. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/manager.py +181 -10
  3. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/sqlite_store.py +39 -5
  4. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/store.py +24 -1
  5. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/protocol/v1alpha1.py +6 -0
  6. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recipes/command.py +16 -0
  7. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/checkpoints.py +49 -0
  8. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/modea.py +139 -4
  9. {flashruntime-0.6.0 → flashruntime-0.6.1/flashruntime.egg-info}/PKG-INFO +1 -1
  10. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/SOURCES.txt +1 -0
  11. {flashruntime-0.6.0 → flashruntime-0.6.1}/pyproject.toml +9 -1
  12. flashruntime-0.6.1/tests/test_leases.py +425 -0
  13. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_leases_sqlite.py +77 -3
  14. flashruntime-0.6.1/tests/test_protocol_lease_fields.py +48 -0
  15. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipes_command.py +31 -0
  16. flashruntime-0.6.1/tests/test_service_checkpoints.py +536 -0
  17. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_delegation.py +39 -12
  18. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_modea.py +120 -0
  19. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_write_scope.py +16 -3
  20. flashruntime-0.6.0/tests/test_leases.py +0 -204
  21. flashruntime-0.6.0/tests/test_service_checkpoints.py +0 -122
  22. {flashruntime-0.6.0 → flashruntime-0.6.1}/LICENSE +0 -0
  23. {flashruntime-0.6.0 → flashruntime-0.6.1}/README.md +0 -0
  24. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/__init__.py +0 -0
  25. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/chunks.py +0 -0
  26. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_driver.py +0 -0
  27. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_weights.py +0 -0
  28. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_worker.py +0 -0
  29. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_driver.py +0 -0
  30. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_shard.py +0 -0
  31. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/sgd_trainer.py +0 -0
  32. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/sharded_kmeans.py +0 -0
  33. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashml_workloads/sklearn_trial.py +0 -0
  34. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/__init__.py +0 -0
  35. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/artifacts/__init__.py +0 -0
  36. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/artifacts/store.py +0 -0
  37. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/backends/__init__.py +0 -0
  38. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/backends/base.py +0 -0
  39. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/backends/kuberay.py +0 -0
  40. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/__init__.py +0 -0
  41. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/catalog.py +0 -0
  42. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/local.py +0 -0
  43. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/checkpoint/store.py +0 -0
  44. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/envelope.py +0 -0
  45. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/TAG +0 -0
  46. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/__init__.py +0 -0
  47. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/python-slim.txt +0 -0
  48. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cpu.txt +0 -0
  49. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cuda.txt +0 -0
  50. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/images/sklearn.txt +0 -0
  51. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/__init__.py +0 -0
  52. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/huggingface.py +0 -0
  53. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/pytorch.py +0 -0
  54. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/integrations/sklearn.py +0 -0
  55. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/launchers/__init__.py +0 -0
  56. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/launchers/local.py +0 -0
  57. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/leases/__init__.py +0 -0
  58. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/monitor/__init__.py +0 -0
  59. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/monitor/sampler.py +0 -0
  60. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/__init__.py +0 -0
  61. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/candidates.py +0 -0
  62. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/catalog.py +0 -0
  63. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/comm.py +0 -0
  64. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/explain.py +0 -0
  65. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/memory.py +0 -0
  66. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/resolve.py +0 -0
  67. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/selector.py +0 -0
  68. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/planner/timecost.py +0 -0
  69. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/profiling/__init__.py +0 -0
  70. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/protocol/__init__.py +0 -0
  71. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  72. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/providers/__init__.py +0 -0
  73. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/py.typed +0 -0
  74. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recipes/__init__.py +0 -0
  75. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recipes/partition.py +0 -0
  76. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/__init__.py +0 -0
  77. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/policy.py +0 -0
  78. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/signals.py +0 -0
  79. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/recovery/taxonomy.py +0 -0
  80. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/reduce.py +0 -0
  81. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/retention.py +0 -0
  82. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/scheduler/__init__.py +0 -0
  83. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/sdk.py +0 -0
  84. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/__init__.py +0 -0
  85. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/app.py +0 -0
  86. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/auth.py +0 -0
  87. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/cli.py +0 -0
  88. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/dashboard.py +0 -0
  89. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/service/ledger.py +0 -0
  90. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/strategies/__init__.py +0 -0
  91. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/strategies/command.py +0 -0
  92. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/torch/__init__.py +0 -0
  93. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/validators.py +0 -0
  94. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/__init__.py +0 -0
  95. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  96. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  97. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/get-started.html +0 -0
  98. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  99. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  100. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  101. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  102. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  103. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/index.html +0 -0
  104. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  105. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  106. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  107. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  108. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/search-index.json +0 -0
  109. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  110. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  111. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  112. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/flowmap.py +0 -0
  113. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/page.py +0 -0
  114. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/server.py +0 -0
  115. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/viewer/state.py +0 -0
  116. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/workloads/__init__.py +0 -0
  117. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime/workloads/command.py +0 -0
  118. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/dependency_links.txt +0 -0
  119. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/entry_points.txt +0 -0
  120. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/requires.txt +0 -0
  121. {flashruntime-0.6.0 → flashruntime-0.6.1}/flashruntime.egg-info/top_level.txt +0 -0
  122. {flashruntime-0.6.0 → flashruntime-0.6.1}/setup.cfg +0 -0
  123. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_auto_recovery.py +0 -0
  124. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_benchmarks.py +0 -0
  125. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_checkpoint.py +0 -0
  126. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_checkpoint_local.py +0 -0
  127. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_chunks.py +0 -0
  128. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_cli_submit.py +0 -0
  129. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_cost_benchmark.py +0 -0
  130. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_declared_validators.py +0 -0
  131. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_documentation.py +0 -0
  132. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_envelope.py +0 -0
  133. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_examples_e2e.py +0 -0
  134. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_convergence.py +0 -0
  135. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_driver.py +0 -0
  136. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_weights.py +0 -0
  137. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_fedavg_worker.py +0 -0
  138. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_gpu_e2e.py +0 -0
  139. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_headline.py +0 -0
  140. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_image_manifest_lookup.py +0 -0
  141. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_integrations.py +0 -0
  142. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_interfaces.py +0 -0
  143. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_job_state_partial.py +0 -0
  144. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_kmeans_leases.py +0 -0
  145. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_kmeans_workload.py +0 -0
  146. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_kuberay_backend.py +0 -0
  147. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_launcher_local.py +0 -0
  148. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_lease_seconds_bound.py +0 -0
  149. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_leases_scope.py +0 -0
  150. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_monitor_sampler.py +0 -0
  151. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_datasets.py +0 -0
  152. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_dependencies.py +0 -0
  153. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_exclusion.py +0 -0
  154. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_gpu.py +0 -0
  155. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_local_data.py +0 -0
  156. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_pool.py +0 -0
  157. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_reliability.py +0 -0
  158. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_placement_trusted_argv.py +0 -0
  159. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_planner.py +0 -0
  160. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_datasets.py +0 -0
  161. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_evidence.py +0 -0
  162. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_gpu.py +0 -0
  163. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_local_datasets.py +0 -0
  164. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_pools.py +0 -0
  165. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_protocol_v1alpha1.py +0 -0
  166. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_datasets.py +0 -0
  167. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_gpu.py +0 -0
  168. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_local_inputs.py +0 -0
  169. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_partition.py +0 -0
  170. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recipe_pool.py +0 -0
  171. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_recovery.py +0 -0
  172. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_reduce.py +0 -0
  173. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_retention.py +0 -0
  174. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_run_json.py +0 -0
  175. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_scheduler_isolation.py +0 -0
  176. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_sdk_submit.py +0 -0
  177. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_artifact_delete.py +0 -0
  178. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_auth.py +0 -0
  179. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_auth_startup.py +0 -0
  180. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_command_recipe.py +0 -0
  181. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_fedavg.py +0 -0
  182. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_job_result.py +0 -0
  183. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_reliability.py +0 -0
  184. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_service_validate_output.py +0 -0
  185. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_sgd_trainer.py +0 -0
  186. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_strategy_command.py +0 -0
  187. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_submit_envelope.py +0 -0
  188. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_submit_retention.py +0 -0
  189. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_torch_helper.py +0 -0
  190. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_flowmap.py +0 -0
  191. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_page.py +0 -0
  192. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_server.py +0 -0
  193. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_viewer_state.py +0 -0
  194. {flashruntime-0.6.0 → flashruntime-0.6.1}/tests/test_workloads_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashruntime
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute.
5
5
  Author: Zolli Labs
6
6
  License-Expression: Apache-2.0
@@ -18,9 +18,26 @@ State transitions (task):
18
18
 
19
19
  PENDING ──claim──▶ LEASED ──complete (first valid)──▶ COMPLETED
20
20
  ▲ │
21
- └──sweep expiry / fail (attempts left)──┘
22
- LEASED ──fail/expiry with no attempts left──▶ FAILED
21
+ └──sweep expiry / fail / revoked-silent / force_expire (budget left)──┘
22
+ LEASED ──fail, failure budget spent──▶ FAILED
23
+ LEASED ──expiry, expiry budget spent──▶ FAILED
23
24
  any non-terminal ──cancel──▶ CANCELLED
25
+
26
+ Failure and expiry spend separate budgets (audit C12): a worker that
27
+ reports N real failures exhausts at `max_attempts`; a lease that lapses
28
+ unclaimed N times (pure churn — no worker ever ran it) exhausts at the
29
+ separate, more generous `max_expiries`. `attempts_used` still numbers
30
+ attempts but decides nothing terminal on its own.
31
+
32
+ Two things can end a lease early besides its deadline (audit C11/§5.1).
33
+ The *progress watchdog*: a heartbeat proves a process is alive, not that
34
+ work is happening, so a holder silent past `spec.max_silent_seconds` has
35
+ its next renewal refused and the task requeued — `note_progress` is what
36
+ resets that clock. It is opt-in (`max_silent_seconds` defaults to 0, which
37
+ disables it): a task that reports no progress signal is not thereby stuck,
38
+ so whoever expands a job arms the window only for work that does report.
39
+ And `force_expire`, the operator's manual version of the same revocation.
40
+ Both spend the expiry budget, because both are churn.
24
41
  """
25
42
 
26
43
  from __future__ import annotations
@@ -80,6 +97,11 @@ class LeaseManager:
80
97
  record.state = TaskState.CANCELLED
81
98
  record.active_lease = None
82
99
  self._store.save(record)
100
+ # Emitted only on a real transition (audit §5.1): the ledger is the
101
+ # only place a cancellation was ever visible, and a task that
102
+ # vanished from the queue with no event read as a coordinator bug.
103
+ # An idempotent re-cancel transitions nothing, so it says nothing.
104
+ self._emit(EventType.TASK_CANCELLED, job_id, task_id, now=now)
83
105
 
84
106
  # -- worker side --------------------------------------------------------
85
107
 
@@ -125,6 +147,7 @@ class LeaseManager:
125
147
  if record is None:
126
148
  return None
127
149
  record.attempts_used += 1
150
+ record.last_progress_at = now
128
151
  lease = Lease(
129
152
  lease_id=f"ls-{uuid.uuid4().hex[:12]}",
130
153
  task_id=record.spec.task_id,
@@ -132,6 +155,9 @@ class LeaseManager:
132
155
  node_id=node_id,
133
156
  attempt_number=record.attempts_used,
134
157
  deadline=now + timedelta(seconds=record.spec.lease_seconds),
158
+ # On the wire so the holder can pick its own heartbeat interval
159
+ # from the real window instead of guessing one (audit §5.1).
160
+ lease_seconds=record.spec.lease_seconds,
135
161
  payload=record.spec.payload,
136
162
  )
137
163
  record.state = TaskState.LEASED
@@ -151,9 +177,38 @@ class LeaseManager:
151
177
  def heartbeat(self, lease_id: str, now: datetime | None = None) -> Lease:
152
178
  """Renew a live lease (extends the deadline by the task's lease
153
179
  window). Refused for unknown/expired/superseded leases — a worker
154
- whose heartbeat is refused must stop working on the task."""
180
+ whose heartbeat is refused must stop working on the task.
181
+
182
+ Also refused, and the lease revoked, when the holder has been alive
183
+ but *silent* past `spec.max_silent_seconds` (audit C11). A heartbeat
184
+ proves a process is running; only `note_progress` proves the work
185
+ is. Without this, a stuck worker whose heartbeat thread survived
186
+ renewed its lease forever and the task never came back — the zombie
187
+ lease. Opt-in: `max_silent_seconds` defaults to 0, which disables
188
+ the watchdog, so it never fires on a workload that reports nothing.
189
+ """
155
190
  now = now or _utcnow()
156
191
  record, lease = self._require_live_lease(lease_id, now)
192
+ # After the liveness check on purpose: an already-dead lease is
193
+ # refused as dead ("no longer live"), not as silent. Only a lease
194
+ # that is still good takes the revocation path below.
195
+ window = record.spec.max_silent_seconds
196
+ baseline = record.last_progress_at
197
+ if window > 0 and baseline is not None and (now - baseline).total_seconds() > window:
198
+ self._emit(
199
+ EventType.LEASE_RENEWAL_REFUSED,
200
+ lease.job_id,
201
+ lease.task_id,
202
+ node_id=lease.node_id,
203
+ detail=f"no progress for {(now - baseline).total_seconds():.0f}s "
204
+ f"(budget {window:.0f}s) — lease revoked, task requeued",
205
+ now=now,
206
+ )
207
+ # Counter bumped at the call site, like fail()/sweep() (audit
208
+ # C12): a revoked lease is churn, so it spends the expiry budget.
209
+ record.expiries_seen += 1
210
+ self._release(record, now, EventType.TASK_REQUEUED, budget="expiry")
211
+ raise LeaseError(f"lease {lease_id} revoked: no progress within {window:.0f}s")
157
212
  renewed = lease.model_copy(
158
213
  update={"deadline": now + timedelta(seconds=record.spec.lease_seconds)}
159
214
  )
@@ -178,6 +233,13 @@ class LeaseManager:
178
233
  """
179
234
  now = now or _utcnow()
180
235
  record, lease = self._find_lease(lease_id)
236
+ if record.state == TaskState.COMPLETED and record.accepted_attempt_id == lease.lease_id:
237
+ # The winner asking again (its 200 was lost to a timeout, and it
238
+ # retried). Answering False told the one machine that actually
239
+ # did the work that it had lost — audit §2 "retried complete".
240
+ # Idempotent means same answer, and no second ACCEPTED event:
241
+ # the ledger already records the commit that happened.
242
+ return True
181
243
  if record.state == TaskState.COMPLETED or not self._is_live(record, lease, now):
182
244
  self._emit(
183
245
  EventType.TASK_COMMIT_REJECTED,
@@ -202,11 +264,50 @@ class LeaseManager:
202
264
  )
203
265
  return True
204
266
 
267
+ def note_progress(
268
+ self, job_id: str, task_id: str, now: datetime | None = None
269
+ ) -> None:
270
+ """Record that the holder made *observable* progress on this task.
271
+
272
+ The other half of the C11 watchdog: heartbeats keep the deadline
273
+ alive, this keeps the silence budget alive. Callers are whatever the
274
+ workload reports through — a metric, a step counter, a partial
275
+ artifact write.
276
+
277
+ A silent no-op for an unknown task or one that is not LEASED: the
278
+ relay can always race an expiry, and a progress note arriving one
279
+ tick after the sweep took the lease away must not resurrect
280
+ anything, nor error at a reporter that did nothing wrong.
281
+ """
282
+ record = self._store.get(job_id, task_id)
283
+ if record is None or record.state != TaskState.LEASED:
284
+ return
285
+ record.last_progress_at = now or _utcnow()
286
+ self._store.save(record)
287
+
205
288
  def fail(self, lease_id: str, reason: str, now: datetime | None = None) -> None:
206
289
  """A worker reports its attempt failed. The task requeues while
207
- attempts remain, otherwise it is FAILED for good."""
290
+ attempts remain, otherwise it is FAILED for good.
291
+
292
+ A report against a *known but dead* lease is recorded and ignored,
293
+ not raised (audit §5.1): the attempt really did fail, and that is
294
+ evidence worth keeping, but the task has already moved on — spending
295
+ its failure budget or touching its state would punish whoever holds
296
+ it now. Only a lease that never existed is an error.
297
+ """
208
298
  now = now or _utcnow()
209
- record, lease = self._require_live_lease(lease_id, now)
299
+ record, lease = self._find_lease(lease_id)
300
+ if not self._is_live(record, lease, now):
301
+ self._emit(
302
+ EventType.TASK_ATTEMPT_FAILED,
303
+ lease.job_id,
304
+ lease.task_id,
305
+ node_id=lease.node_id,
306
+ detail=f"(late report — lease no longer live) {reason}",
307
+ now=now,
308
+ )
309
+ return
310
+ record.failures_seen += 1
210
311
  self._emit(
211
312
  EventType.TASK_ATTEMPT_FAILED,
212
313
  lease.job_id,
@@ -215,7 +316,7 @@ class LeaseManager:
215
316
  detail=reason,
216
317
  now=now,
217
318
  )
218
- self._release(record, now, requeue_event=EventType.TASK_REQUEUED)
319
+ self._release(record, now, EventType.TASK_REQUEUED, budget="failure")
219
320
 
220
321
  # -- coordinator side ---------------------------------------------------
221
322
 
@@ -230,6 +331,7 @@ class LeaseManager:
230
331
  if lease is None or lease.deadline > now:
231
332
  continue
232
333
  expired += 1
334
+ record.expiries_seen += 1
233
335
  self._emit(
234
336
  EventType.LEASE_EXPIRED,
235
337
  lease.job_id,
@@ -238,9 +340,37 @@ class LeaseManager:
238
340
  detail=f"attempt {lease.attempt_number} heartbeat deadline passed",
239
341
  now=now,
240
342
  )
241
- self._release(record, now, requeue_event=EventType.TASK_REQUEUED)
343
+ self._release(record, now, EventType.TASK_REQUEUED, budget="expiry")
242
344
  return expired
243
345
 
346
+ def force_expire(
347
+ self, job_id: str, task_id: str, reason: str, now: datetime | None = None
348
+ ) -> bool:
349
+ """Revoke a task's live lease now, by operator decision.
350
+
351
+ The manual lever behind the automatic ones (audit §5.1): when a
352
+ machine is known-stuck but its deadline is minutes away, waiting is
353
+ the only thing an operator could do. Returns False when the task is
354
+ not LEASED — there is nothing to take away, and re-running the same
355
+ command is then a no-op rather than an error.
356
+ """
357
+ now = now or _utcnow()
358
+ record = self._require(job_id, task_id)
359
+ if record.state != TaskState.LEASED:
360
+ return False
361
+ lease = record.active_lease
362
+ self._emit(
363
+ EventType.LEASE_EXPIRED,
364
+ job_id,
365
+ task_id,
366
+ node_id=lease.node_id if lease is not None else None,
367
+ detail=f"force-expired: {reason}",
368
+ now=now,
369
+ )
370
+ record.expiries_seen += 1
371
+ self._release(record, now, EventType.TASK_REQUEUED, budget="expiry")
372
+ return True
373
+
244
374
  def job_state(self, job_id: str) -> dict[str, int]:
245
375
  """Task counts by state — the number the dashboard shows."""
246
376
  counts: dict[str, int] = {}
@@ -260,6 +390,20 @@ class LeaseManager:
260
390
  return None
261
391
  return lease
262
392
 
393
+ def lease_is_live(self, lease_id: str, now: datetime | None = None) -> bool:
394
+ """Is this lease still the good one? A question, so it answers.
395
+
396
+ Unlike `_find_lease`, an unknown id is False rather than an error:
397
+ callers ask this to decide whether to keep going, and "I have never
398
+ heard of that lease" is a perfectly good "stop".
399
+ """
400
+ now = now if now is not None else _utcnow()
401
+ try:
402
+ record, lease = self._find_lease(lease_id)
403
+ except LeaseError:
404
+ return False
405
+ return self._is_live(record, lease, now)
406
+
263
407
  def live_leases_for_node(
264
408
  self, node_id: str, now: datetime | None = None
265
409
  ) -> set[tuple[str, str]]:
@@ -294,15 +438,42 @@ class LeaseManager:
294
438
 
295
439
  # -- internals ----------------------------------------------------------
296
440
 
297
- def _release(self, record: TaskRecord, now: datetime, requeue_event: EventType) -> None:
441
+ def _release(
442
+ self,
443
+ record: TaskRecord,
444
+ now: datetime,
445
+ requeue_event: EventType,
446
+ *,
447
+ budget: str,
448
+ ) -> None:
449
+ """Return a task to PENDING, or exhaust it — against the budget the
450
+ caller spent (audit C12).
451
+
452
+ `"failure"` and `"expiry"` are separate budgets on purpose: a
453
+ worker that keeps reporting real failures is different evidence
454
+ from a lease that keeps lapsing unclaimed (pure churn — no worker
455
+ ever ran it), so each is checked against its own counter
456
+ (`spec.max_attempts` / `spec.max_expiries`) and neither spends the
457
+ other's.
458
+ """
298
459
  record.active_lease = None
299
- if record.attempts_used >= record.spec.max_attempts:
460
+ exhausted = (
461
+ record.failures_seen >= record.spec.max_attempts
462
+ if budget == "failure"
463
+ else record.expiries_seen >= record.spec.max_expiries
464
+ )
465
+ if exhausted:
300
466
  record.state = TaskState.FAILED
467
+ detail = (
468
+ f"all {record.spec.max_attempts} attempts failed"
469
+ if budget == "failure"
470
+ else f"lease expired {record.expiries_seen} times without completing"
471
+ )
301
472
  self._emit(
302
473
  EventType.TASK_EXHAUSTED,
303
474
  record.spec.job_id,
304
475
  record.spec.task_id,
305
- detail=f"all {record.spec.max_attempts} attempts used",
476
+ detail=detail,
306
477
  now=now,
307
478
  )
308
479
  else:
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import sqlite3
19
+ from datetime import datetime
19
20
  from pathlib import Path
20
21
 
21
22
  from flashruntime.leases.store import TaskRecord
@@ -49,6 +50,7 @@ class SqliteLeaseStore:
49
50
  self._conn = sqlite3.connect(str(path), check_same_thread=False)
50
51
  self._migrate() # legacy single-PK table → composite
51
52
  self._conn.executescript(_SCHEMA)
53
+ self._ensure_runtime_column() # additive: budget-split counters (audit C12)
52
54
  self._cache: dict[tuple[str, str], TaskRecord] = {}
53
55
  self._seq = 0
54
56
  self._load()
@@ -79,15 +81,27 @@ class SqliteLeaseStore:
79
81
  )
80
82
  self._conn.commit()
81
83
 
84
+ def _ensure_runtime_column(self) -> None:
85
+ """Additive migration (audit C12): one nullable JSON column carries
86
+ the budget-split counters (`failures_seen`, `expiries_seen`,
87
+ `last_progress_at`). A plain `ADD COLUMN` — unlike the composite-key
88
+ rebuild above, no rewrite is needed since every existing row simply
89
+ reads back NULL, and `_load` treats that as the pre-upgrade
90
+ zeros/None defaults rather than failing to load."""
91
+ cols = {row[1] for row in self._conn.execute("PRAGMA table_info(lease_tasks)").fetchall()}
92
+ if "runtime_json" not in cols:
93
+ self._conn.execute("ALTER TABLE lease_tasks ADD COLUMN runtime_json TEXT")
94
+ self._conn.commit()
95
+
82
96
  # -- rehydration ---------------------------------------------------------
83
97
 
84
98
  def _load(self) -> None:
85
99
  rows = self._conn.execute(
86
100
  "SELECT spec_json, state, attempts_used, active_lease_json,"
87
- " accepted_attempt_id, lease_history_json, seq"
101
+ " accepted_attempt_id, lease_history_json, runtime_json, seq"
88
102
  " FROM lease_tasks ORDER BY seq"
89
103
  ).fetchall()
90
- for spec_json, state, attempts, lease_json, accepted, history_json, seq in rows:
104
+ for spec_json, state, attempts, lease_json, accepted, history_json, runtime_json, seq in rows:
91
105
  record = TaskRecord(TaskSpec.model_validate_json(spec_json))
92
106
  record.state = TaskState(state)
93
107
  record.attempts_used = attempts
@@ -97,6 +111,15 @@ class SqliteLeaseStore:
97
111
  lid: Lease.model_validate(raw)
98
112
  for lid, raw in json.loads(history_json).items()
99
113
  }
114
+ # `.get(...)` defaults: a row written before this column existed
115
+ # (or with it explicitly NULL) loads as zeros/None, not an error.
116
+ runtime = json.loads(runtime_json) if runtime_json else {}
117
+ record.failures_seen = runtime.get("failures_seen", 0)
118
+ record.expiries_seen = runtime.get("expiries_seen", 0)
119
+ last_progress_at = runtime.get("last_progress_at")
120
+ record.last_progress_at = (
121
+ datetime.fromisoformat(last_progress_at) if last_progress_at else None
122
+ )
100
123
  self._cache[(record.spec.job_id, record.spec.task_id)] = record
101
124
  self._seq = max(self._seq, seq or 0)
102
125
 
@@ -140,18 +163,28 @@ class SqliteLeaseStore:
140
163
  history = json.dumps(
141
164
  {lid: json.loads(lease.model_dump_json()) for lid, lease in record.lease_history.items()}
142
165
  )
166
+ runtime = json.dumps(
167
+ {
168
+ "failures_seen": record.failures_seen,
169
+ "expiries_seen": record.expiries_seen,
170
+ "last_progress_at": (
171
+ record.last_progress_at.isoformat() if record.last_progress_at else None
172
+ ),
173
+ }
174
+ )
143
175
  self._conn.execute(
144
176
  "INSERT INTO lease_tasks"
145
177
  " (task_id, job_id, spec_json, state, attempts_used, active_lease_json,"
146
- " accepted_attempt_id, lease_history_json, seq)"
147
- " VALUES (?, ?, ?, ?, ?, ?, ?, ?,"
178
+ " accepted_attempt_id, lease_history_json, runtime_json, seq)"
179
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,"
148
180
  " COALESCE(?, (SELECT seq FROM lease_tasks"
149
181
  " WHERE job_id = ? AND task_id = ?)))"
150
182
  " ON CONFLICT(job_id, task_id) DO UPDATE SET"
151
183
  " state=excluded.state, attempts_used=excluded.attempts_used,"
152
184
  " active_lease_json=excluded.active_lease_json,"
153
185
  " accepted_attempt_id=excluded.accepted_attempt_id,"
154
- " lease_history_json=excluded.lease_history_json",
186
+ " lease_history_json=excluded.lease_history_json,"
187
+ " runtime_json=excluded.runtime_json",
155
188
  (
156
189
  record.spec.task_id,
157
190
  record.spec.job_id,
@@ -161,6 +194,7 @@ class SqliteLeaseStore:
161
194
  record.active_lease.model_dump_json() if record.active_lease else None,
162
195
  record.accepted_attempt_id,
163
196
  history,
197
+ runtime,
164
198
  seq,
165
199
  record.spec.job_id,
166
200
  record.spec.task_id,
@@ -11,6 +11,7 @@ manager, never in a store, so every backend behaves identically.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ from datetime import datetime
14
15
  from typing import Protocol
15
16
 
16
17
  from flashruntime.protocol.v1alpha1 import Lease, TaskSpec, TaskState
@@ -22,9 +23,28 @@ class TaskRecord:
22
23
  `lease_history` keeps every lease ever issued for the task so a late
23
24
  commit from a long-dead worker can be *identified and rejected* rather
24
25
  than erroring as unknown — rejected duplicates are recovery evidence.
26
+
27
+ `failures_seen` and `expiries_seen` (audit C12) are separate budgets:
28
+ a worker that reports a failure spends `failures_seen` against
29
+ `spec.max_attempts`; a lease that lapses unclaimed spends
30
+ `expiries_seen` against `spec.max_expiries`. `attempts_used` still
31
+ numbers attempts (and drives `Lease.attempt_number`), but no longer by
32
+ itself decides anything terminal. `last_progress_at` is stamped on
33
+ every claim — the last time some worker showed up for this task at
34
+ all, regardless of how that attempt ended.
25
35
  """
26
36
 
27
- __slots__ = ("spec", "state", "attempts_used", "active_lease", "accepted_attempt_id", "lease_history")
37
+ __slots__ = (
38
+ "spec",
39
+ "state",
40
+ "attempts_used",
41
+ "active_lease",
42
+ "accepted_attempt_id",
43
+ "lease_history",
44
+ "failures_seen",
45
+ "expiries_seen",
46
+ "last_progress_at",
47
+ )
28
48
 
29
49
  def __init__(self, spec: TaskSpec):
30
50
  self.spec = spec
@@ -33,6 +53,9 @@ class TaskRecord:
33
53
  self.active_lease: Lease | None = None
34
54
  self.accepted_attempt_id: str | None = None
35
55
  self.lease_history: dict[str, Lease] = {}
56
+ self.failures_seen = 0
57
+ self.expiries_seen = 0
58
+ self.last_progress_at: datetime | None = None
36
59
 
37
60
 
38
61
  class LeaseStore(Protocol):
@@ -233,6 +233,9 @@ class EventType(str, Enum):
233
233
  FAILURE_CLASSIFIED = "FAILURE_CLASSIFIED"
234
234
  RECOVERY_ACTION_SELECTED = "RECOVERY_ACTION_SELECTED"
235
235
  RECOVERY_FROZEN = "RECOVERY_FROZEN"
236
+ # Lease hardening (additive, August 2026)
237
+ TASK_CANCELLED = "TASK_CANCELLED"
238
+ LEASE_RENEWAL_REFUSED = "LEASE_RENEWAL_REFUSED"
236
239
 
237
240
 
238
241
  class Event(BaseModel):
@@ -462,6 +465,8 @@ class TaskSpec(BaseModel):
462
465
  commit_key: str
463
466
  max_attempts: int = Field(default=3, ge=1)
464
467
  lease_seconds: float = Field(default=60.0, gt=0)
468
+ max_expiries: int = Field(default=9, ge=1) # lease-expiry budget, separate from max_attempts
469
+ max_silent_seconds: float = Field(default=0.0, ge=0) # renewal watchdog window; 0 (default) disables — the coordinator arms this at task-expansion time for tasks that produce progress signals
465
470
 
466
471
  @field_validator("lease_seconds")
467
472
  @classmethod
@@ -492,6 +497,7 @@ class Lease(BaseModel):
492
497
  node_id: str
493
498
  attempt_number: int
494
499
  deadline: datetime
500
+ lease_seconds: float = 0.0 # issued by a coordinator that predates this field if 0.0
495
501
  payload: dict[str, Any] = Field(default_factory=dict)
496
502
 
497
503
 
@@ -233,6 +233,22 @@ class CommandRecipe(WorkloadRecipe):
233
233
  # dies on ModuleNotFoundError, and both ends' unit tests
234
234
  # still pass.
235
235
  payload["dependencies"] = list(p["dependencies"])
236
+ if p.get("python") is not None:
237
+ # The interpreter `dependencies` above was resolved against
238
+ # ("3.11", major.minor, CPython) — declared by the submitter
239
+ # in flashml.yaml or derived by the compiler from the curated
240
+ # image the submitter chose. A no-container host provisions
241
+ # exactly this interpreter for the job's venv instead of
242
+ # trusting whatever python it happens to run.
243
+ #
244
+ # This forward exists because its absence was observed live
245
+ # on 2026-08-13: an Alibaba FC sandbox on Python 3.13 pulled
246
+ # pins wheeled only to cp312, every environment build died in
247
+ # a source compile, and the scheduler re-fed the same node
248
+ # until a healthy task was EXHAUSTED. Same failure shape as
249
+ # `dependencies` above: drop this line and both ends' unit
250
+ # tests still pass while the fleet fragments by interpreter.
251
+ payload["python"] = p["python"]
236
252
  if p.get("extra_dependencies"):
237
253
  # The extras alone, named separately from `dependencies`
238
254
  # above: the eighth placement gate keys on THIS field, not
@@ -26,6 +26,33 @@ def _scope(job_id: str, task_id: str) -> str:
26
26
  return f"{job_id}::{task_id}"
27
27
 
28
28
 
29
+ def _require_live_lease_on_this_task(manager, attempt_id: str, job_id: str, task_id: str) -> None:
30
+ """410 unless `attempt_id` is a lease that is both LIVE and *this
31
+ path's* (job_id, task_id) — not merely live somewhere.
32
+
33
+ `manager.lease_is_live(attempt_id)` alone answers a global question:
34
+ "is this lease id still the good one for whatever task it was issued
35
+ against?" That is true for any node currently holding any lease at
36
+ all, so checking liveness alone let a caller present a live lease from
37
+ task A against task B's checkpoint path — writing into a foreign
38
+ task's checkpoint lineage and resetting a foreign task's watchdog
39
+ baseline via `note_progress`. Binding the lease to the path pair
40
+ closes that: the presented attempt must be *this* task's own current
41
+ attempt, not merely proof that the caller holds work somewhere.
42
+ """
43
+ lease = manager.lease_info(attempt_id)
44
+ if (
45
+ lease is None
46
+ or lease.job_id != job_id
47
+ or lease.task_id != task_id
48
+ or not manager.lease_is_live(attempt_id)
49
+ ):
50
+ raise HTTPException(
51
+ status_code=410,
52
+ detail="lease is no longer live — checkpoint write refused",
53
+ )
54
+
55
+
29
56
  class RegisterPartRequest(BaseModel):
30
57
  attempt_id: str
31
58
  step: int = Field(ge=0)
@@ -48,12 +75,29 @@ def build_router(catalog: CheckpointCatalog, state, manager) -> APIRouter:
48
75
  @router.post("/parts")
49
76
  async def register_part(job_id: str, task_id: str, req: RegisterPartRequest, request: Request):
50
77
  authorize_task_write(state, manager, request, job_id, task_id)
78
+ # Fencing (audit C13/§5.4): `attempt_id` must name the lease that is
79
+ # STILL the live one, AND still this task's own current attempt —
80
+ # not a live lease borrowed from somewhere else. A zombie whose
81
+ # lease already expired — the heartbeat thread survived past the
82
+ # sweep, or the watchdog revoked it — must not be able to keep
83
+ # writing checkpoint bytes for a task another node now holds, and
84
+ # a caller holding a DIFFERENT live task must not be able to write
85
+ # into or reset the watchdog for THIS one. 410, same as a refused
86
+ # heartbeat: the caller must stop.
87
+ _require_live_lease_on_this_task(manager, req.attempt_id, job_id, task_id)
51
88
  catalog.register_part(_scope(job_id, task_id), req.attempt_id, req.step, req.part)
89
+ # Progress signal for the watchdog (audit C11), only now: after the
90
+ # fencing check passed and the write actually landed. Called with no
91
+ # `now` so the server clock stamps it — a client-supplied timestamp
92
+ # would let a stalled worker manufacture its own liveness.
93
+ manager.note_progress(job_id, task_id)
52
94
  return {"status": "registered", "key": req.part.key}
53
95
 
54
96
  @router.post("/commit")
55
97
  async def commit(job_id: str, task_id: str, req: CommitRequest, request: Request):
56
98
  authorize_task_write(state, manager, request, job_id, task_id)
99
+ # Same fencing as register_part above — see that comment.
100
+ _require_live_lease_on_this_task(manager, req.attempt_id, job_id, task_id)
57
101
  try:
58
102
  manifest = catalog.commit(
59
103
  job_id=_scope(job_id, task_id),
@@ -67,7 +111,12 @@ def build_router(catalog: CheckpointCatalog, state, manager) -> APIRouter:
67
111
  )
68
112
  except CheckpointError as exc:
69
113
  # 409: the manifest was refused — parts missing or hashes wrong.
114
+ # No progress note: a rejected commit proved nothing about the
115
+ # work, so it must not reset the silence clock either.
70
116
  raise HTTPException(status_code=409, detail=str(exc))
117
+ # Only after the catalog accepted the manifest — a rejected commit
118
+ # above never reaches this line.
119
+ manager.note_progress(job_id, task_id)
71
120
  return manifest
72
121
 
73
122
  @router.get("/latest")