flashruntime 0.5.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.5.0/flashruntime.egg-info → flashruntime-0.6.1}/PKG-INFO +1 -1
  2. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/manager.py +181 -10
  3. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/sqlite_store.py +39 -5
  4. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/store.py +24 -1
  5. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/protocol/v1alpha1.py +17 -0
  6. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recipes/command.py +53 -0
  7. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/scheduler/__init__.py +92 -0
  8. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/checkpoints.py +49 -0
  9. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/modea.py +139 -4
  10. {flashruntime-0.5.0 → flashruntime-0.6.1/flashruntime.egg-info}/PKG-INFO +1 -1
  11. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/SOURCES.txt +4 -0
  12. {flashruntime-0.5.0 → flashruntime-0.6.1}/pyproject.toml +9 -1
  13. flashruntime-0.6.1/tests/test_leases.py +425 -0
  14. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_leases_sqlite.py +77 -3
  15. flashruntime-0.6.1/tests/test_placement_datasets.py +354 -0
  16. flashruntime-0.6.1/tests/test_protocol_datasets.py +28 -0
  17. flashruntime-0.6.1/tests/test_protocol_lease_fields.py +48 -0
  18. flashruntime-0.6.1/tests/test_recipe_datasets.py +95 -0
  19. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipes_command.py +31 -0
  20. flashruntime-0.6.1/tests/test_service_checkpoints.py +536 -0
  21. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_delegation.py +39 -12
  22. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_modea.py +120 -0
  23. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_write_scope.py +16 -3
  24. flashruntime-0.5.0/tests/test_leases.py +0 -204
  25. flashruntime-0.5.0/tests/test_service_checkpoints.py +0 -122
  26. {flashruntime-0.5.0 → flashruntime-0.6.1}/LICENSE +0 -0
  27. {flashruntime-0.5.0 → flashruntime-0.6.1}/README.md +0 -0
  28. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/__init__.py +0 -0
  29. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/chunks.py +0 -0
  30. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_driver.py +0 -0
  31. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_weights.py +0 -0
  32. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/fedavg_worker.py +0 -0
  33. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_driver.py +0 -0
  34. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/kmeans_shard.py +0 -0
  35. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/sgd_trainer.py +0 -0
  36. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/sharded_kmeans.py +0 -0
  37. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashml_workloads/sklearn_trial.py +0 -0
  38. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/__init__.py +0 -0
  39. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/artifacts/__init__.py +0 -0
  40. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/artifacts/store.py +0 -0
  41. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/backends/__init__.py +0 -0
  42. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/backends/base.py +0 -0
  43. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/backends/kuberay.py +0 -0
  44. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/__init__.py +0 -0
  45. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/catalog.py +0 -0
  46. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/local.py +0 -0
  47. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/checkpoint/store.py +0 -0
  48. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/envelope.py +0 -0
  49. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/TAG +0 -0
  50. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/__init__.py +0 -0
  51. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/python-slim.txt +0 -0
  52. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cpu.txt +0 -0
  53. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/pytorch-cuda.txt +0 -0
  54. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/images/sklearn.txt +0 -0
  55. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/__init__.py +0 -0
  56. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/huggingface.py +0 -0
  57. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/pytorch.py +0 -0
  58. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/integrations/sklearn.py +0 -0
  59. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/launchers/__init__.py +0 -0
  60. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/launchers/local.py +0 -0
  61. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/leases/__init__.py +0 -0
  62. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/monitor/__init__.py +0 -0
  63. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/monitor/sampler.py +0 -0
  64. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/__init__.py +0 -0
  65. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/candidates.py +0 -0
  66. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/catalog.py +0 -0
  67. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/comm.py +0 -0
  68. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/explain.py +0 -0
  69. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/memory.py +0 -0
  70. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/resolve.py +0 -0
  71. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/selector.py +0 -0
  72. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/planner/timecost.py +0 -0
  73. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/profiling/__init__.py +0 -0
  74. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/protocol/__init__.py +0 -0
  75. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  76. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/providers/__init__.py +0 -0
  77. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/py.typed +0 -0
  78. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recipes/__init__.py +0 -0
  79. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recipes/partition.py +0 -0
  80. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/__init__.py +0 -0
  81. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/policy.py +0 -0
  82. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/signals.py +0 -0
  83. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/recovery/taxonomy.py +0 -0
  84. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/reduce.py +0 -0
  85. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/retention.py +0 -0
  86. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/sdk.py +0 -0
  87. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/__init__.py +0 -0
  88. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/app.py +0 -0
  89. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/auth.py +0 -0
  90. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/cli.py +0 -0
  91. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/dashboard.py +0 -0
  92. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/service/ledger.py +0 -0
  93. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/strategies/__init__.py +0 -0
  94. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/strategies/command.py +0 -0
  95. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/torch/__init__.py +0 -0
  96. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/validators.py +0 -0
  97. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/__init__.py +0 -0
  98. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  99. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  100. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/get-started.html +0 -0
  101. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  102. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  103. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  104. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  105. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  106. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/index.html +0 -0
  107. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  108. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  109. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  110. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  111. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/search-index.json +0 -0
  112. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  113. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  114. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  115. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/flowmap.py +0 -0
  116. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/page.py +0 -0
  117. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/server.py +0 -0
  118. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/viewer/state.py +0 -0
  119. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/workloads/__init__.py +0 -0
  120. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime/workloads/command.py +0 -0
  121. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/dependency_links.txt +0 -0
  122. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/entry_points.txt +0 -0
  123. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/requires.txt +0 -0
  124. {flashruntime-0.5.0 → flashruntime-0.6.1}/flashruntime.egg-info/top_level.txt +0 -0
  125. {flashruntime-0.5.0 → flashruntime-0.6.1}/setup.cfg +0 -0
  126. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_auto_recovery.py +0 -0
  127. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_benchmarks.py +0 -0
  128. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_checkpoint.py +0 -0
  129. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_checkpoint_local.py +0 -0
  130. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_chunks.py +0 -0
  131. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_cli_submit.py +0 -0
  132. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_cost_benchmark.py +0 -0
  133. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_declared_validators.py +0 -0
  134. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_documentation.py +0 -0
  135. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_envelope.py +0 -0
  136. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_examples_e2e.py +0 -0
  137. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_convergence.py +0 -0
  138. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_driver.py +0 -0
  139. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_weights.py +0 -0
  140. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_fedavg_worker.py +0 -0
  141. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_gpu_e2e.py +0 -0
  142. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_headline.py +0 -0
  143. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_image_manifest_lookup.py +0 -0
  144. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_integrations.py +0 -0
  145. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_interfaces.py +0 -0
  146. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_job_state_partial.py +0 -0
  147. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_kmeans_leases.py +0 -0
  148. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_kmeans_workload.py +0 -0
  149. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_kuberay_backend.py +0 -0
  150. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_launcher_local.py +0 -0
  151. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_lease_seconds_bound.py +0 -0
  152. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_leases_scope.py +0 -0
  153. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_monitor_sampler.py +0 -0
  154. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_dependencies.py +0 -0
  155. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_exclusion.py +0 -0
  156. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_gpu.py +0 -0
  157. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_local_data.py +0 -0
  158. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_pool.py +0 -0
  159. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_reliability.py +0 -0
  160. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_placement_trusted_argv.py +0 -0
  161. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_planner.py +0 -0
  162. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_evidence.py +0 -0
  163. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_gpu.py +0 -0
  164. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_local_datasets.py +0 -0
  165. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_pools.py +0 -0
  166. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_protocol_v1alpha1.py +0 -0
  167. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_gpu.py +0 -0
  168. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_local_inputs.py +0 -0
  169. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_partition.py +0 -0
  170. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recipe_pool.py +0 -0
  171. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_recovery.py +0 -0
  172. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_reduce.py +0 -0
  173. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_retention.py +0 -0
  174. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_run_json.py +0 -0
  175. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_scheduler_isolation.py +0 -0
  176. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_sdk_submit.py +0 -0
  177. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_artifact_delete.py +0 -0
  178. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_auth.py +0 -0
  179. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_auth_startup.py +0 -0
  180. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_command_recipe.py +0 -0
  181. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_fedavg.py +0 -0
  182. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_job_result.py +0 -0
  183. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_reliability.py +0 -0
  184. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_service_validate_output.py +0 -0
  185. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_sgd_trainer.py +0 -0
  186. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_strategy_command.py +0 -0
  187. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_submit_envelope.py +0 -0
  188. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_submit_retention.py +0 -0
  189. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_torch_helper.py +0 -0
  190. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_flowmap.py +0 -0
  191. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_page.py +0 -0
  192. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_server.py +0 -0
  193. {flashruntime-0.5.0 → flashruntime-0.6.1}/tests/test_viewer_state.py +0 -0
  194. {flashruntime-0.5.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.5.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):
@@ -332,6 +335,17 @@ class NodeCapabilities(BaseModel):
332
335
  #: this runtime cannot parse — never a guess. Placement reads the LENGTH
333
336
  #: of this list and nothing else in v1.
334
337
  gpus: list[GpuInfo] = Field(default_factory=list)
338
+ #: Bytes this host will lend to cached dataset shards. Advertised so the
339
+ #: control plane can refuse a job at SUBMIT time when no host in the pool
340
+ #: can hold a slice — twenty machines each downloading for forty minutes
341
+ #: and then refusing is the failure this exists to replace.
342
+ #:
343
+ #: Defaults to 0, and 0 means "send me no dataset work". Fail closed, the
344
+ #: same polarity as `can_install_dependencies`: an agent whose
345
+ #: registration predates this field has nowhere to put a shard and must
346
+ #: never be handed one. Bytes rather than GB to match `memory_bytes` —
347
+ #: one unit for every capacity on this model.
348
+ dataset_cache_bytes: int = 0
335
349
  #: Team pools this node serves, as pool ids minted by the cloud control
336
350
  #: plane. Stamped SERVER-SIDE by the cloud API's agent proxy from the
337
351
  #: machine owner's memberships — an agent's self-reported value is
@@ -451,6 +465,8 @@ class TaskSpec(BaseModel):
451
465
  commit_key: str
452
466
  max_attempts: int = Field(default=3, ge=1)
453
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
454
470
 
455
471
  @field_validator("lease_seconds")
456
472
  @classmethod
@@ -481,6 +497,7 @@ class Lease(BaseModel):
481
497
  node_id: str
482
498
  attempt_number: int
483
499
  deadline: datetime
500
+ lease_seconds: float = 0.0 # issued by a coordinator that predates this field if 0.0
484
501
  payload: dict[str, Any] = Field(default_factory=dict)
485
502
 
486
503
 
@@ -66,6 +66,18 @@ class CommandRecipe(WorkloadRecipe):
66
66
  )
67
67
  elif not all(isinstance(d, str) for d in extra_deps):
68
68
  problems.append("'extra_dependencies' must be a list of strings")
69
+ slices = params.get("dataset_slices")
70
+ if slices is not None:
71
+ if not isinstance(slices, list) or isinstance(slices, (str, bytes)):
72
+ problems.append(
73
+ "'dataset_slices' must be a list with one entry per task "
74
+ "(a list, not a single string)"
75
+ )
76
+ elif not all(isinstance(s, list) for s in slices):
77
+ problems.append(
78
+ "'dataset_slices' must be a list of per-task lists, one "
79
+ "list of dataset slices for each task"
80
+ )
69
81
  problems.extend(self._unpack_problems(params))
70
82
  problems.extend(partition_problems(params))
71
83
  problems.extend(declaration_problems(params.get("validators")))
@@ -221,6 +233,22 @@ class CommandRecipe(WorkloadRecipe):
221
233
  # dies on ModuleNotFoundError, and both ends' unit tests
222
234
  # still pass.
223
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"]
224
252
  if p.get("extra_dependencies"):
225
253
  # The extras alone, named separately from `dependencies`
226
254
  # above: the eighth placement gate keys on THIS field, not
@@ -239,6 +267,31 @@ class CommandRecipe(WorkloadRecipe):
239
267
  # hop have tests that pass while it is broken, because each
240
268
  # constructs the payload directly.
241
269
  payload["extra_dependencies"] = list(p["extra_dependencies"])
270
+ if p.get("dataset_slices") is not None:
271
+ # The ONLY per-task payload key in this method. Every other
272
+ # forward above copies one value to every task; each task
273
+ # fetches a DIFFERENT slice of the manifest, so this one is
274
+ # indexed by task position exactly as `task_params` is.
275
+ #
276
+ # Computed by the flashml-cloud compiler and opaque here:
277
+ # the byte-weighted mapping lives beside `fleet_shape` in
278
+ # that repo's `elastic.py`, and a second implementation here
279
+ # is the drift that module's docstring warns about.
280
+ #
281
+ # Same failure shape as `local_inputs` and `dependencies`:
282
+ # drop this line and every host fetches nothing, the workload
283
+ # dies on a missing path, and both ends' unit tests still
284
+ # pass because each constructs the payload directly.
285
+ slices = p["dataset_slices"]
286
+ if i >= len(slices):
287
+ raise ValueError(
288
+ f"task {i}: 'dataset_slices' has {len(slices)} "
289
+ f"entr{'y' if len(slices) == 1 else 'ies'} but this "
290
+ f"job expanded to more tasks — every task needs a "
291
+ f"slice, even an empty one"
292
+ )
293
+ # `list(...)` so a payload never aliases the caller's spec.
294
+ payload["datasets"] = list(slices[i])
242
295
  gpus = spec.spec.resources.gpuPerTask
243
296
  if gpus:
244
297
  # The GPU requirement is the ONLY payload key sourced from
@@ -386,6 +386,56 @@ class IsolationAwarePlacement(PlacementPolicy):
386
386
  waiver relaxes the sandbox tier and nothing else. No waiver makes a
387
387
  read-only container with no network able to run `pip install`.
388
388
 
389
+ A ninth gate applies to tasks whose payload carries `datasets`: the sum
390
+ of every slice entry's `size` must fit inside the claiming node's
391
+ `capabilities.dataset_cache_bytes`. This is CAPACITY, not capability —
392
+ every tier can fetch, because the AGENT fetches before the sandbox
393
+ closes, so unlike `extra_dependencies` there is no tier that physically
394
+ cannot. The only question is whether the bytes fit.
395
+
396
+ Not to be confused with the fourth gate. `local_inputs`/`local_datasets`
397
+ asks whether a host already HOLDS named data it will never upload; this
398
+ one asks whether a host has ROOM for data it is about to fetch from a
399
+ public origin. Different payload key, different capability, different
400
+ question.
401
+
402
+ It takes the argv/local-data/gpu/exclude_nodes/pool/dependencies
403
+ polarity — **fail closed** — and every leg of it:
404
+
405
+ - A node advertising nothing (an agent whose registration predates the
406
+ field) is refused, and so is one advertising `0`, which is the model
407
+ default and means "send me no dataset work". A shard with nowhere to
408
+ go is a task that dies after a forty-minute download rather than one
409
+ that never started, and twenty machines doing that at once is the
410
+ failure the advertisement exists to prevent.
411
+ - The advertisement must be a genuine `int`. `bool` is excluded
412
+ explicitly for the reason the `gpus` gate excludes it: it is an `int`
413
+ subclass, so a `dataset_cache_bytes: true` typo would read as the
414
+ capacity 1.
415
+ - `capabilities` itself may be absent or type-confused; that is read as
416
+ advertising nothing rather than allowed to raise. `isinstance`, never
417
+ `(node.get("capabilities") or {})` — a string `capabilities` value has
418
+ no `.get` and would crash the predicate inside the claim path, taking
419
+ the whole claim down rather than one task.
420
+ - A non-dict slice, a non-dict entry, or an entry whose `size` is not a
421
+ non-negative `int` makes the task ineligible everywhere. The manifest
422
+ is submitter-controlled, so an unreadable size must NOT contribute 0 to
423
+ the total: that is precisely how a hostile slice would buy itself an
424
+ unbounded fetch on a node that advertised a modest cache.
425
+ - The requirement is the sum over every entry of every slice. Checking
426
+ per-dataset, or against the largest entry, would place a task that then
427
+ fills a volunteer's disk halfway through the fetch.
428
+ - Empty and absent both mean "declares no data": `if
429
+ task.payload.get("datasets")` treats `None` and `[]` alike, so every
430
+ job deployed today is completely unaffected. A slice that is PRESENT
431
+ but happens to carry no entries is still dataset work and still needs
432
+ a readable advertisement — the fail-closed leg is the advertisement,
433
+ not the total.
434
+
435
+ `allowFallback` does not waive this gate either. It waives the sandbox
436
+ tier and nothing else; a submitter's isolation posture has nothing to say
437
+ about how much disk a stranger's machine offered.
438
+
389
439
  Everything genuinely standard keeps the fail-open placement default."""
390
440
 
391
441
  def eligible(self, task: TaskSpec, node: NodeView) -> bool:
@@ -516,6 +566,48 @@ class IsolationAwarePlacement(PlacementPolicy):
516
566
  if task.payload.get("extra_dependencies"):
517
567
  if node.get("can_install_dependencies") is not True:
518
568
  return False
569
+ # Capacity, not capability, and the only gate in this class that is.
570
+ # Every tier's AGENT has network and a writable disk before the
571
+ # sandbox closes, so unlike `extra_dependencies` there is no tier
572
+ # that physically cannot fetch — the only question is whether the
573
+ # bytes fit. Distinct from the `local_inputs` gate above: that one
574
+ # asks whether a host already HOLDS named data, this one whether it
575
+ # has ROOM for data it is about to fetch.
576
+ #
577
+ # Fail-closed on an absent or type-confused advertisement, and
578
+ # checked before the allowFallback waiver below for the same reason
579
+ # as every gate above: the waiver relaxes the sandbox tier and has
580
+ # nothing to say about how much disk a stranger's machine offered.
581
+ if task.payload.get("datasets"):
582
+ needed = 0
583
+ for dataset in task.payload["datasets"]:
584
+ if not isinstance(dataset, dict):
585
+ return False # type-confused slice ⇒ fail closed, no crash
586
+ for entry in dataset.get("entries") or []:
587
+ size = entry.get("size") if isinstance(entry, dict) else None
588
+ # A non-int size is a malformed slice, not a free one:
589
+ # letting it contribute 0 is how a hostile manifest buys
590
+ # an unbounded fetch on a modest cache. `bool` is an
591
+ # `int` subclass, so `size: true` is excluded explicitly.
592
+ if (
593
+ not isinstance(size, int)
594
+ or isinstance(size, bool)
595
+ or size < 0
596
+ ):
597
+ return False
598
+ needed += size
599
+ capabilities = node.get("capabilities")
600
+ # isinstance, not `or {}` — a string capabilities value has no
601
+ # `.get` and must fail closed rather than crash the predicate.
602
+ advertised = (
603
+ capabilities.get("dataset_cache_bytes")
604
+ if isinstance(capabilities, dict)
605
+ else None
606
+ )
607
+ if not isinstance(advertised, int) or isinstance(advertised, bool):
608
+ return False # absent/type-confused ⇒ advertises nothing
609
+ if needed > advertised:
610
+ return False
519
611
  isolation = task.payload.get("isolation")
520
612
  if isolation is None:
521
613
  return True # no isolation payload ⇒ standard, runs anywhere