letify 2.6.2__tar.gz → 2.6.4__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 (210) hide show
  1. {letify-2.6.2 → letify-2.6.4}/PKG-INFO +1 -1
  2. {letify-2.6.2 → letify-2.6.4}/docs/INTENT.md +23 -1
  3. {letify-2.6.2 → letify-2.6.4}/docs/SPEC.md +31 -1
  4. {letify-2.6.2 → letify-2.6.4}/letify/__init__.py +1 -1
  5. {letify-2.6.2 → letify-2.6.4}/letify/declare/reduce.py +164 -18
  6. {letify-2.6.2 → letify-2.6.4}/letify/providers/modal.py +47 -4
  7. {letify-2.6.2 → letify-2.6.4}/letify/runtime/broker.py +24 -3
  8. {letify-2.6.2 → letify-2.6.4}/letify/runtime/session.py +22 -3
  9. {letify-2.6.2 → letify-2.6.4}/letify-core/Cargo.lock +3 -3
  10. {letify-2.6.2 → letify-2.6.4}/letify-core/Cargo.toml +1 -1
  11. {letify-2.6.2 → letify-2.6.4}/letify-ext/package-lock.json +2 -2
  12. {letify-2.6.2 → letify-2.6.4}/letify-ext/package.json +1 -1
  13. {letify-2.6.2 → letify-2.6.4}/pyproject.toml +1 -1
  14. {letify-2.6.2 → letify-2.6.4}/tests/test_distributed.py +75 -0
  15. {letify-2.6.2 → letify-2.6.4}/tests/test_providers.py +32 -1
  16. {letify-2.6.2 → letify-2.6.4}/tests/test_runtime.py +29 -0
  17. {letify-2.6.2 → letify-2.6.4}/.github/workflows/ci.yml +0 -0
  18. {letify-2.6.2 → letify-2.6.4}/.github/workflows/docs.yml +0 -0
  19. {letify-2.6.2 → letify-2.6.4}/.github/workflows/publish.yml +0 -0
  20. {letify-2.6.2 → letify-2.6.4}/.gitignore +0 -0
  21. {letify-2.6.2 → letify-2.6.4}/.researchtree +0 -0
  22. {letify-2.6.2 → letify-2.6.4}/CLAUDE.md +0 -0
  23. {letify-2.6.2 → letify-2.6.4}/LICENSE +0 -0
  24. {letify-2.6.2 → letify-2.6.4}/PROJECT.md +0 -0
  25. {letify-2.6.2 → letify-2.6.4}/README.md +0 -0
  26. {letify-2.6.2 → letify-2.6.4}/docs/COMPONENT.md +0 -0
  27. {letify-2.6.2 → letify-2.6.4}/docs/NETWORK.md +0 -0
  28. {letify-2.6.2 → letify-2.6.4}/docs/guide/01-getting-started.md +0 -0
  29. {letify-2.6.2 → letify-2.6.4}/docs/guide/02-providers.md +0 -0
  30. {letify-2.6.2 → letify-2.6.4}/docs/guide/03-execution-modes.md +0 -0
  31. {letify-2.6.2 → letify-2.6.4}/docs/guide/04-environments-and-data.md +0 -0
  32. {letify-2.6.2 → letify-2.6.4}/docs/guide/05-concurrency.md +0 -0
  33. {letify-2.6.2 → letify-2.6.4}/docs/guide/06-cost.md +0 -0
  34. {letify-2.6.2 → letify-2.6.4}/docs/guide/07-troubleshooting.md +0 -0
  35. {letify-2.6.2 → letify-2.6.4}/docs/guide/08-releasing.md +0 -0
  36. {letify-2.6.2 → letify-2.6.4}/docs/guide/README.md +0 -0
  37. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  38. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  39. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  40. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  41. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  42. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  43. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  44. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  45. {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/README.md +0 -0
  46. {letify-2.6.2 → letify-2.6.4}/docs/locales/README_ko.md +0 -0
  47. {letify-2.6.2 → letify-2.6.4}/docs/mkdocs.yml +0 -0
  48. {letify-2.6.2 → letify-2.6.4}/examples/README.md +0 -0
  49. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/.letify.example +0 -0
  50. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/00_smoke.py +0 -0
  51. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/01_sweep.py +0 -0
  52. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/02_resume.py +0 -0
  53. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/03_watch.py +0 -0
  54. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/README.md +0 -0
  55. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/README_ko.md +0 -0
  56. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/common.py +0 -0
  57. {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/recipe.py +0 -0
  58. {letify-2.6.2 → letify-2.6.4}/letify/cli.py +0 -0
  59. {letify-2.6.2 → letify-2.6.4}/letify/config/__init__.py +0 -0
  60. {letify-2.6.2 → letify-2.6.4}/letify/config/inventory.py +0 -0
  61. {letify-2.6.2 → letify-2.6.4}/letify/config/login.py +0 -0
  62. {letify-2.6.2 → letify-2.6.4}/letify/config/schema.py +0 -0
  63. {letify-2.6.2 → letify-2.6.4}/letify/config/secrets.py +0 -0
  64. {letify-2.6.2 → letify-2.6.4}/letify/config/writer.py +0 -0
  65. {letify-2.6.2 → letify-2.6.4}/letify/declare/__init__.py +0 -0
  66. {letify-2.6.2 → letify-2.6.4}/letify/declare/cache.py +0 -0
  67. {letify-2.6.2 → letify-2.6.4}/letify/declare/env.py +0 -0
  68. {letify-2.6.2 → letify-2.6.4}/letify/declare/function.py +0 -0
  69. {letify-2.6.2 → letify-2.6.4}/letify/declare/instance.py +0 -0
  70. {letify-2.6.2 → letify-2.6.4}/letify/declare/replicas.py +0 -0
  71. {letify-2.6.2 → letify-2.6.4}/letify/errors.py +0 -0
  72. {letify-2.6.2 → letify-2.6.4}/letify/install.py +0 -0
  73. {letify-2.6.2 → letify-2.6.4}/letify/launcher.py +0 -0
  74. {letify-2.6.2 → letify-2.6.4}/letify/paths.py +0 -0
  75. {letify-2.6.2 → letify-2.6.4}/letify/protocol/__init__.py +0 -0
  76. {letify-2.6.2 → letify-2.6.4}/letify/protocol/codec.py +0 -0
  77. {letify-2.6.2 → letify-2.6.4}/letify/protocol/driver.py +0 -0
  78. {letify-2.6.2 → letify-2.6.4}/letify/protocol/handle.py +0 -0
  79. {letify-2.6.2 → letify-2.6.4}/letify/protocol/wire.py +0 -0
  80. {letify-2.6.2 → letify-2.6.4}/letify/protocol/worker.py +0 -0
  81. {letify-2.6.2 → letify-2.6.4}/letify/providers/__init__.py +0 -0
  82. {letify-2.6.2 → letify-2.6.4}/letify/providers/base.py +0 -0
  83. {letify-2.6.2 → letify-2.6.4}/letify/providers/colab.py +0 -0
  84. {letify-2.6.2 → letify-2.6.4}/letify/providers/colab_files.py +0 -0
  85. {letify-2.6.2 → letify-2.6.4}/letify/providers/colab_keepalive.py +0 -0
  86. {letify-2.6.2 → letify-2.6.4}/letify/providers/elice.py +0 -0
  87. {letify-2.6.2 → letify-2.6.4}/letify/providers/kaggle.py +0 -0
  88. {letify-2.6.2 → letify-2.6.4}/letify/providers/kaggle_adapter.py +0 -0
  89. {letify-2.6.2 → letify-2.6.4}/letify/providers/local.py +0 -0
  90. {letify-2.6.2 → letify-2.6.4}/letify/providers/modal_adapter.py +0 -0
  91. {letify-2.6.2 → letify-2.6.4}/letify/providers/naming.py +0 -0
  92. {letify-2.6.2 → letify-2.6.4}/letify/providers/shell.py +0 -0
  93. {letify-2.6.2 → letify-2.6.4}/letify/providers/tunnel.py +0 -0
  94. {letify-2.6.2 → letify-2.6.4}/letify/providers/usage.py +0 -0
  95. {letify-2.6.2 → letify-2.6.4}/letify/remoting/__init__.py +0 -0
  96. {letify-2.6.2 → letify-2.6.4}/letify/remoting/capability.py +0 -0
  97. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/__init__.py +0 -0
  98. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/client.py +0 -0
  99. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/cuda.py +0 -0
  100. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/executor.py +0 -0
  101. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/frames.py +0 -0
  102. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/guard.py +0 -0
  103. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/tensor.py +0 -0
  104. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/trace.py +0 -0
  105. {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/value.py +0 -0
  106. {letify-2.6.2 → letify-2.6.4}/letify/remoting/loader.py +0 -0
  107. {letify-2.6.2 → letify-2.6.4}/letify/remoting/probe.py +0 -0
  108. {letify-2.6.2 → letify-2.6.4}/letify/render.py +0 -0
  109. {letify-2.6.2 → letify-2.6.4}/letify/runtime/__init__.py +0 -0
  110. {letify-2.6.2 → letify-2.6.4}/letify/runtime/bootstrap.py +0 -0
  111. {letify-2.6.2 → letify-2.6.4}/letify/runtime/channel.py +0 -0
  112. {letify-2.6.2 → letify-2.6.4}/letify/runtime/lease.py +0 -0
  113. {letify-2.6.2 → letify-2.6.4}/letify/runtime/pool.py +0 -0
  114. {letify-2.6.2 → letify-2.6.4}/letify/runtime/telemetry.py +0 -0
  115. {letify-2.6.2 → letify-2.6.4}/letify/store/__init__.py +0 -0
  116. {letify-2.6.2 → letify-2.6.4}/letify/store/backends/__init__.py +0 -0
  117. {letify-2.6.2 → letify-2.6.4}/letify/store/backends/filesystem.py +0 -0
  118. {letify-2.6.2 → letify-2.6.4}/letify/store/backends/google_auth.py +0 -0
  119. {letify-2.6.2 → letify-2.6.4}/letify/store/backends/layout.py +0 -0
  120. {letify-2.6.2 → letify-2.6.4}/letify/store/backends/objects.py +0 -0
  121. {letify-2.6.2 → letify-2.6.4}/letify/store/cas.py +0 -0
  122. {letify-2.6.2 → letify-2.6.4}/letify/store/pathdata.py +0 -0
  123. {letify-2.6.2 → letify-2.6.4}/letify/store/sendorder.py +0 -0
  124. {letify-2.6.2 → letify-2.6.4}/letify/store/volume.py +0 -0
  125. {letify-2.6.2 → letify-2.6.4}/letify/stubs.py +0 -0
  126. {letify-2.6.2 → letify-2.6.4}/letify/tools.py +0 -0
  127. {letify-2.6.2 → letify-2.6.4}/letify/transport/__init__.py +0 -0
  128. {letify-2.6.2 → letify-2.6.4}/letify/transport/agent.py +0 -0
  129. {letify-2.6.2 → letify-2.6.4}/letify/transport/announce.py +0 -0
  130. {letify-2.6.2 → letify-2.6.4}/letify/transport/link.py +0 -0
  131. {letify-2.6.2 → letify-2.6.4}/letify/transport/nat.py +0 -0
  132. {letify-2.6.2 → letify-2.6.4}/letify/transport/pipeline.py +0 -0
  133. {letify-2.6.2 → letify-2.6.4}/letify/transport/portmap.py +0 -0
  134. {letify-2.6.2 → letify-2.6.4}/letify/transport/probe.py +0 -0
  135. {letify-2.6.2 → letify-2.6.4}/letify/transport/quic.py +0 -0
  136. {letify-2.6.2 → letify-2.6.4}/letify/transport/rendezvous.py +0 -0
  137. {letify-2.6.2 → letify-2.6.4}/letify/transport/setup.py +0 -0
  138. {letify-2.6.2 → letify-2.6.4}/letify/transport/sshopts.py +0 -0
  139. {letify-2.6.2 → letify-2.6.4}/letify/transport/strategies.py +0 -0
  140. {letify-2.6.2 → letify-2.6.4}/letify-core/.gitignore +0 -0
  141. {letify-2.6.2 → letify-2.6.4}/letify-core/build.py +0 -0
  142. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-agent/Cargo.toml +0 -0
  143. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-agent/src/driver.rs +0 -0
  144. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-agent/src/main.rs +0 -0
  145. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/Cargo.toml +0 -0
  146. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/client.rs +0 -0
  147. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/exports.rs +0 -0
  148. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/image.rs +0 -0
  149. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/lib.rs +0 -0
  150. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/status.rs +0 -0
  151. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/table.rs +0 -0
  152. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-quic/Cargo.toml +0 -0
  153. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-quic/src/main.rs +0 -0
  154. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-wire/Cargo.toml +0 -0
  155. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-wire/src/lib.rs +0 -0
  156. {letify-2.6.2 → letify-2.6.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
  157. {letify-2.6.2 → letify-2.6.4}/letify-core/rust-toolchain.toml +0 -0
  158. {letify-2.6.2 → letify-2.6.4}/letify-ext/.vscodeignore +0 -0
  159. {letify-2.6.2 → letify-2.6.4}/letify-ext/LICENSE +0 -0
  160. {letify-2.6.2 → letify-2.6.4}/letify-ext/README.md +0 -0
  161. {letify-2.6.2 → letify-2.6.4}/letify-ext/media/icon.svg +0 -0
  162. {letify-2.6.2 → letify-2.6.4}/letify-ext/src/cli.ts +0 -0
  163. {letify-2.6.2 → letify-2.6.4}/letify-ext/src/extension.ts +0 -0
  164. {letify-2.6.2 → letify-2.6.4}/letify-ext/src/history.ts +0 -0
  165. {letify-2.6.2 → letify-2.6.4}/letify-ext/src/model.ts +0 -0
  166. {letify-2.6.2 → letify-2.6.4}/letify-ext/src/poll.ts +0 -0
  167. {letify-2.6.2 → letify-2.6.4}/letify-ext/src/render.ts +0 -0
  168. {letify-2.6.2 → letify-2.6.4}/letify-ext/test/cli.test.ts +0 -0
  169. {letify-2.6.2 → letify-2.6.4}/letify-ext/test/manifest.test.ts +0 -0
  170. {letify-2.6.2 → letify-2.6.4}/letify-ext/test/model.test.ts +0 -0
  171. {letify-2.6.2 → letify-2.6.4}/letify-ext/test/regressions.test.ts +0 -0
  172. {letify-2.6.2 → letify-2.6.4}/letify-ext/tsconfig.json +0 -0
  173. {letify-2.6.2 → letify-2.6.4}/letify_providers.py +0 -0
  174. {letify-2.6.2 → letify-2.6.4}/scripts/sync-main.sh +0 -0
  175. {letify-2.6.2 → letify-2.6.4}/scripts/version.py +0 -0
  176. {letify-2.6.2 → letify-2.6.4}/tests/conftest.py +0 -0
  177. {letify-2.6.2 → letify-2.6.4}/tests/fake_kaggle_adapter.py +0 -0
  178. {letify-2.6.2 → letify-2.6.4}/tests/fake_modal_adapter.py +0 -0
  179. {letify-2.6.2 → letify-2.6.4}/tests/test_cli.py +0 -0
  180. {letify-2.6.2 → letify-2.6.4}/tests/test_cli_output.py +0 -0
  181. {letify-2.6.2 → letify-2.6.4}/tests/test_colab_keepalive.py +0 -0
  182. {letify-2.6.2 → letify-2.6.4}/tests/test_config.py +0 -0
  183. {letify-2.6.2 → letify-2.6.4}/tests/test_core.py +0 -0
  184. {letify-2.6.2 → letify-2.6.4}/tests/test_data.py +0 -0
  185. {letify-2.6.2 → letify-2.6.4}/tests/test_data_streaming.py +0 -0
  186. {letify-2.6.2 → letify-2.6.4}/tests/test_declare.py +0 -0
  187. {letify-2.6.2 → letify-2.6.4}/tests/test_device.py +0 -0
  188. {letify-2.6.2 → letify-2.6.4}/tests/test_editor_sessions.py +0 -0
  189. {letify-2.6.2 → letify-2.6.4}/tests/test_elice.py +0 -0
  190. {letify-2.6.2 → letify-2.6.4}/tests/test_frames.py +0 -0
  191. {letify-2.6.2 → letify-2.6.4}/tests/test_install.py +0 -0
  192. {letify-2.6.2 → letify-2.6.4}/tests/test_kaggle.py +0 -0
  193. {letify-2.6.2 → letify-2.6.4}/tests/test_kaggle_cookie.py +0 -0
  194. {letify-2.6.2 → letify-2.6.4}/tests/test_kaggle_link.py +0 -0
  195. {letify-2.6.2 → letify-2.6.4}/tests/test_link_log.py +0 -0
  196. {letify-2.6.2 → letify-2.6.4}/tests/test_notebook.py +0 -0
  197. {letify-2.6.2 → letify-2.6.4}/tests/test_parallel_placement.py +0 -0
  198. {letify-2.6.2 → letify-2.6.4}/tests/test_paths.py +0 -0
  199. {letify-2.6.2 → letify-2.6.4}/tests/test_portmap.py +0 -0
  200. {letify-2.6.2 → letify-2.6.4}/tests/test_protocol.py +0 -0
  201. {letify-2.6.2 → letify-2.6.4}/tests/test_quic.py +0 -0
  202. {letify-2.6.2 → letify-2.6.4}/tests/test_remoting.py +0 -0
  203. {letify-2.6.2 → letify-2.6.4}/tests/test_render.py +0 -0
  204. {letify-2.6.2 → letify-2.6.4}/tests/test_ship.py +0 -0
  205. {letify-2.6.2 → letify-2.6.4}/tests/test_store.py +0 -0
  206. {letify-2.6.2 → letify-2.6.4}/tests/test_strategies.py +0 -0
  207. {letify-2.6.2 → letify-2.6.4}/tests/test_stubs.py +0 -0
  208. {letify-2.6.2 → letify-2.6.4}/tests/test_transport.py +0 -0
  209. {letify-2.6.2 → letify-2.6.4}/tests/test_tunnel_setup.py +0 -0
  210. {letify-2.6.2 → letify-2.6.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.6.2
3
+ Version: 2.6.4
4
4
  Summary: Declarations that become infrastructure.
5
5
  Project-URL: Homepage, https://github.com/DarkPyonix/letify
6
6
  Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
@@ -74,6 +74,28 @@ cross cloud to cloud and the client carries the mappings and nothing else. Measu
74
74
  inbound on the network this was designed on is capped near 0.25 MiB/s, against 21 MiB/s
75
75
  outbound to a near runtime, which is why this is a claim rather than an optimization.
76
76
 
77
+ **TCP hole punching does not reach between two Kaggle runtimes.** Measured on 2026-10-13 between two
78
+ Google Cloud VMs on two Kaggle accounts: both NATs preserved the port, 45117 on each, so the
79
+ signalling and the mappings were right, and every SYN was dropped for the whole 45 second window from
80
+ both sides at once. So the mechanism this claim names works client to runtime, where it was measured,
81
+ and not runtime to runtime on that network. The claim is not refuted, because what it asserts is that
82
+ the reduction must not pass through the client, and that still holds. What is refuted is that a TCP
83
+ punch is the way to arrange it on Kaggle.
84
+
85
+ **A UDP punch does not reach either.** Measured the same way on the same pair of accounts: both NATs
86
+ preserved the port, 45223 on each, both sides sent datagrams for 30 seconds, and neither received
87
+ one. So neither protocol reaches between two Kaggle runtimes with a mapping learned from a third
88
+ party, and the distributed placement has no runtime to runtime transport on that pair today. A pair
89
+ that cannot connect refuses the reduction, which is what letify does, so the behaviour is right and
90
+ the placement is unusable there rather than slow.
91
+
92
+ Two paths remain, and which one letify takes is an open decision rather than something to assume.
93
+ Either both runtimes dial outward to a third host that is not the client, which is a relay and has to
94
+ be argued against the constraint that a slow link is a failure, or they exchange parameters through
95
+ object storage in one of their clouds, which letify already reaches with the `bucket` account option
96
+ and the GCS backend. The second needs no inbound anywhere, which is the property the measurements
97
+ above say is missing.
98
+
77
99
  ## How an experiment reports efficiency
78
100
 
79
101
  This standard sits here rather than in `docs/SPEC.md` because it governs how a claim is tested, not what the system does. The spec records the design; this records what a report has to show before a claim above may be called supported. Every experiment pull request follows it.
@@ -108,7 +130,7 @@ Each of these would change a claim or a default. Answering one is a good first e
108
130
 
109
131
  1. **How many concurrent sessions does one Colab account allow?** Undocumented, and it moves with tier, credit balance and demand. Until it is measured, a `devices` count in the provider entry is where the answer goes, so a user who has measured their own account is not overruled by a number letify guessed.
110
132
  2. **What is the real host synchronization count per step, `k`, for the target workload?** Measurable with `torch.cuda.set_sync_debug_mode("warn")`. This sets whether PyTorch forwarding is worth using for that workload.
111
- 3. **How often does TCP hole punching succeed on the networks researchers actually use?** It succeeded between a Colab VM and a university network in Korea, where both NATs preserved the port. Home routers, office networks and mobile tethering are unmeasured. This decides how often the pipeline falls to UDP or to the provider's own path.
133
+ 3. **How often does TCP hole punching succeed on the networks researchers actually use?** It succeeded between a Colab VM and a university network in Korea, where both NATs preserved the port. It failed between two Google Cloud VMs on two Kaggle accounts, where both NATs also preserved the port and every SYN was dropped, so a preserved port is not sufficient. Home routers, office networks and mobile tethering are unmeasured. This decides how often the pipeline falls to UDP or to the provider's own path, and runtime to runtime it decides whether the distributed placement has a transport on a given pair of providers.
112
134
  4. **Is NVFP4 reachable in a stock Colab runtime?** Needs the CUDA version, the compute capability and whether the quantization stack installs.
113
135
  5. **Is the Elice SSH port stable across a restart?** If it is not, the configuration needs a command that resolves the current port.
114
136
  6. **What does Elice spot pricing cost?** The API exposes a pricing id, which suggests preemptible instances are available. This is a direct cost lever.
@@ -111,6 +111,30 @@ parameters into the weak direction every round. A pair of replicas that cannot p
111
111
  fails the reduction and says so; it does not fall back to the client, for the reason
112
112
  [Link floor](#link-floor) gives.
113
113
 
114
+ A replica joins a round with two addresses for the listener it opened: the one its own interface
115
+ reports, and the one a STUN server says its NAT gives that port. The broker passes both to every
116
+ other joined replica, with a token the pair agrees a connection on and the time both start dialing,
117
+ which is what [TCP hole punching](#tcp-punch) already needs from the client when the client is one
118
+ of the two ends. A replica then tries the address its peer reported for itself first, because two
119
+ replicas on one machine or one network reach each other without a punch, and punches to the mapped
120
+ address when that connection is refused. The punch is the same `punch` call a strategy makes. The
121
+ replica that would have dialed anyway, the one that is not the leader, is the initiator, so the two
122
+ sides never both take that role whatever their ranks are.
123
+
124
+ The distributed placement is supported on a pair of runtimes that can reach each other, and nowhere
125
+ else. Two Kaggle runtimes cannot: measured on 2026-10-13, both NATs preserved the port and every TCP
126
+ SYN was dropped for 45 s, and a UDP punch on the same pair sent for 30 s each way without a datagram
127
+ arriving. letify therefore refuses such a pair rather than routing the reduction through the client
128
+ or a relay, which is the decision `docs/INTENT.md` records against claim N10. A declaration naming
129
+ two runtimes that cannot connect fails, and the measurement to make before using a provider pair is
130
+ whether they punch at all.
131
+
132
+ Punching is between a pair, because one punched connection takes one local port on each side and
133
+ the two sides have to agree which pair that port belongs to. So a round of more than two replicas
134
+ whose members cannot reach each other directly is refused, naming the ranks that could not connect,
135
+ rather than punching a mesh letify has not measured. Two replicas is the case claim N9's boundary is
136
+ measured on, and more than two over direct addresses already works.
137
+
114
138
  ### How often to synchronize <!-- id: distributed-local-steps -->
115
139
 
116
140
  > `letify.local_steps(param_bytes, step_seconds)` answers how many steps to take between
@@ -783,7 +807,11 @@ A `read_until` blocks until the sandbox prints a line, and the adapter answers o
783
807
  - `Modal.stop` calls `abort()` when the adapter is out of step or its lock is still held after 5 s. The call timeout watchdog calls it through `SandboxChannel._kill`, so a call past its `timeout` ends its sandbox too.
784
808
  - The adapter's `terminate` for an id it did not create finds the sandbox with `modal.Sandbox.from_id` and terminates it. A sandbox that is already gone is not an error.
785
809
 
786
- Modal also bounds a sandbox that nothing terminates. `create` passes `timeout`, the entry option `timeout`, 3600 s by default, as the sandbox's maximum lifetime. It passes `idle_timeout`, the entry option `idle_timeout`, 600 s by default, after which Modal terminates a sandbox that is idle.
810
+ Modal also bounds a sandbox that nothing terminates. `create` passes `idle_timeout`, the entry option `idle_timeout`, 600 s by default, after which Modal terminates a sandbox that is idle. That is the bound that stops an abandoned sandbox billing, because an abandoned one is idle by definition once its worker has no one to answer.
811
+
812
+ It passes `timeout`, the entry option `timeout`, as the sandbox's maximum lifetime, and the default is the longest Modal accepts, 86400 s, measured as accepted. It is not a shorter number letify picked, for the reason The declaration gives for `timeout` having no default: a deadline letify invented would end a two hour training run at whatever hour it guessed. The default was 3600 s, exactly one hour, and it ended two Modal sessions of a real training run at one hour each with nothing saying why, which is that sentence happening. An account that wants a hard cap sets `timeout` itself.
813
+
814
+ A sandbox that reaches that lifetime takes its worker with it, so the channel sees a worker that stopped without replying. Where the session's age is within a minute of the lifetime letify asked for, the failure says so instead of listing causes: `the Modal sandbox reached the <seconds> s lifetime letify asked for; raise timeout on the account to run longer`.
787
815
 
788
816
  ### Modal data channel <!-- id: modal-data-channel -->
789
817
 
@@ -1078,6 +1106,8 @@ Before a blob is written to a destination that is not unpacked, the local proces
1078
1106
 
1079
1107
  A persistent provider builds its environment with `uv sync` in `<workspace root>/project/<env key>` every session, and never packs the project directory into a volume or unpacks an archive from one. A sync over an existing `.venv` checks it and installs nothing, so it is faster than any archive transfer, and the `.venv` is already on the disk the archive would be unpacked to.
1080
1108
 
1109
+ That holds only while the project directory is under the workspace root. A provider that keeps its workspace root and still builds the `.venv` elsewhere archives like an ephemeral one, because the directory the sync writes does not survive the session however durable the workspace root is. Modal is that provider: its workspace root is a Modal volume and its `env_root` is the sandbox's own disk, chosen because a cold import from the volume reads thousands of small files over the network. Without this, Modal rebuilt 230 packages on every session, measured at 428 s and 534 s on an A10, and had neither the persistence its workspace root offers nor the archive an ephemeral provider gets. Whether the archive is then worth packing is the measurement Materializing into a runtime already makes.
1110
+
1081
1111
  An ephemeral provider keeps the behaviour of Materializing into a runtime: every file is sent each session, and the environment archive is packed and restored.
1082
1112
 
1083
1113
  Nothing hands a session to the caller. There is no call that returns one, no argument that takes one, and no way to hold the wrong one, because which session serves a call is the pool's answer to work out from the declaration.
@@ -66,7 +66,7 @@ distributed: Final = Host.distributed
66
66
  # The enum class stays importable from letify.declare.instance for letify's own use.
67
67
  del Host
68
68
 
69
- __version__ = "2.6.2"
69
+ __version__ = "2.6.4"
70
70
 
71
71
 
72
72
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -68,6 +68,7 @@ def all_reduce(
68
68
  op: Callable[[list], Any] | None = None,
69
69
  timeout: float = DEFAULT_TIMEOUT,
70
70
  min_peers: int | None = None,
71
+ _watch: dict | None = None,
71
72
  ) -> int:
72
73
  """Replace ``tensor``'s contents with the reduction of every replica's, in place.
73
74
 
@@ -111,19 +112,16 @@ def all_reduce(
111
112
  round_id = _ROUND
112
113
 
113
114
  deadline = time.monotonic() + timeout
114
- listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
+ listener, port, mapped = _listener(world)
116
+ host = _local_address()
115
117
  try:
116
- listener.bind(("0.0.0.0", 0))
117
- listener.listen(world)
118
- port = listener.getsockname()[1]
119
- host = _local_address()
120
-
121
118
  main._reduce_join(
122
119
  {
123
120
  "op": "reduce_join",
124
121
  "round": round_id,
125
122
  "rank": my_rank,
126
123
  "address": [host, port],
124
+ "mapped": list(mapped) if mapped is not None else None,
127
125
  "timeout": timeout,
128
126
  "min_peers": needed,
129
127
  }
@@ -138,22 +136,58 @@ def all_reduce(
138
136
 
139
137
  leader = int(table["leader"])
140
138
  count = int(table["count"])
141
- peers = {int(r): tuple(address) for r, address in table["peers"].items()}
142
- header = {"rank": my_rank, "nbytes": len(raw)}
139
+ token = str(table.get("token", ""))
140
+ start_at = float(table.get("start_at", time.time()))
141
+ peers = {
142
+ int(r): {
143
+ "address": tuple(entry["address"]),
144
+ "mapped": tuple(entry["mapped"]) if entry.get("mapped") else None,
145
+ }
146
+ for r, entry in table["peers"].items()
147
+ }
148
+ header = {"rank": my_rank, "round": round_id, "nbytes": len(raw)}
149
+
150
+ if _watch is not None:
151
+ _watch["peers"] = table.get("peers")
152
+ _watch["token"] = token
153
+ _watch["start_at"] = start_at
143
154
 
144
155
  if my_rank == leader:
145
- result = _lead(listener, deadline, my_rank, tensor, header, raw, count, op)
156
+ result = _lead(
157
+ listener, deadline, my_rank, tensor, header, raw, count, op, peers, token,
158
+ start_at, port, round_id,
159
+ )
146
160
  else:
147
- result = _follow(peers[leader], deadline, header, raw)
161
+ result = _follow(leader, peers, deadline, header, raw, token, start_at, port, count)
148
162
  except OSError as exc:
149
163
  raise ReductionFailed(f"all_reduce round {round_id} failed: {exc}") from exc
150
- finally:
151
- listener.close()
152
164
 
153
165
  _write_back(tensor, result)
154
166
  return count
155
167
 
156
168
 
169
+ #: The replica's listener, its port and its STUN mapping, opened once and kept.
170
+ #: Spec "Reducing between replicas": a loop that reduces every H steps would otherwise
171
+ #: pay a STUN round trip and a fresh punch on every round.
172
+ _LISTENER: list = []
173
+ _LISTENER_LOCK = threading.Lock()
174
+
175
+
176
+ def _listener(world: int) -> tuple[Any, int, tuple[str, int] | None]:
177
+ """This replica's listening socket, its port and its mapped address, made once."""
178
+ with _LISTENER_LOCK:
179
+ if not _LISTENER:
180
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
181
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
182
+ if hasattr(socket, "SO_REUSEPORT"):
183
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
184
+ sock.bind(("0.0.0.0", 0))
185
+ sock.listen(max(1, world))
186
+ port = sock.getsockname()[1]
187
+ _LISTENER.append((sock, port, _mapped_address(port)))
188
+ return _LISTENER[0]
189
+
190
+
157
191
  def _local_address() -> str:
158
192
  """The host peers can reach this one on. No configuration knob: it is the machine's
159
193
  own address, as spec "Reducing between replicas" scopes the local transport."""
@@ -163,6 +197,40 @@ def _local_address() -> str:
163
197
  return "127.0.0.1"
164
198
 
165
199
 
200
+ def _mapped_address(port: int) -> tuple[str, int] | None:
201
+ """The STUN mapping of the listener's own ``port``, or None when it cannot be had.
202
+
203
+ Spec "Reducing between replicas": a replica joins a round with two addresses, its
204
+ own and this one. Never raises: a replica with no mapping still joins, with
205
+ ``mapped`` absent, and is still reachable on its own address.
206
+ """
207
+ try:
208
+ from ..transport import nat
209
+ except ImportError:
210
+ return None
211
+ try:
212
+ return nat.stun_mapping(port)
213
+ except Exception:
214
+ return None
215
+
216
+
217
+ def _pair_for_punching(*, count: int, ranks: list[int]) -> tuple[int, int]:
218
+ """The two ranks a punch is between, or a refusal naming every rank in the round.
219
+
220
+ Spec "Reducing between replicas": one punched connection takes one local port on
221
+ each side, so punching a round of more than two replicas is refused rather than
222
+ attempted as a mesh letify has not measured.
223
+ """
224
+ ordered = sorted(ranks)
225
+ if count == 2 and len(ordered) == 2:
226
+ return ordered[0], ordered[1]
227
+ named = ", ".join(str(r) for r in ordered)
228
+ raise ReductionFailed(
229
+ f"a round of {count} replicas over ranks {named} cannot punch to reach each "
230
+ "other directly: punching is between a pair, not a mesh of more than two"
231
+ )
232
+
233
+
166
234
  def _await_table(main: Any, round_id: int, deadline: float) -> dict:
167
235
  """Block until the client answers this round's join, or the deadline passes."""
168
236
  cond = main._REDUCE_LOCK
@@ -187,18 +255,62 @@ def _lead(
187
255
  raw: bytes,
188
256
  count: int,
189
257
  op: Callable[[list], Any],
258
+ peers: dict[int, dict],
259
+ token: str,
260
+ start_at: float,
261
+ port: int,
262
+ round_id: int,
190
263
  ) -> Any:
191
- """Accept every other joined peer, reduce, and send the result back to each."""
264
+ """Accept every other joined peer, punching for one that never connects, then reduce
265
+ and send the result back to each.
266
+
267
+ Spec "Reducing between replicas": the leader accepts on its own listener first,
268
+ because two replicas on one machine or one network reach each other without a
269
+ punch, and punches with ``initiator=False`` only for a peer that never arrives.
270
+ """
192
271
  collected: dict[int, Any] = {my_rank: tensor}
193
272
  conns: list[socket.socket] = []
194
273
  try:
274
+ window_deadline = min(deadline, start_at + _window())
195
275
  while len(collected) < count:
196
- listener.settimeout(_remaining(deadline))
197
- conn, _addr = listener.accept()
276
+ remaining_window = window_deadline - time.monotonic()
277
+ if remaining_window <= 0:
278
+ break
279
+ listener.settimeout(max(0.01, min(remaining_window, _remaining(deadline))))
280
+ try:
281
+ conn, _addr = listener.accept()
282
+ except TimeoutError:
283
+ break
198
284
  conn.settimeout(_remaining(deadline))
199
285
  peer_header, payload = _recv_message(conn)
286
+ if int(peer_header.get("round", round_id)) != round_id:
287
+ # The listener outlives a round, so a connection a previous one left
288
+ # behind can be accepted here. Spec "Reducing between replicas".
289
+ conn.close()
290
+ continue
200
291
  collected[int(peer_header["rank"])] = _rebuild(tensor, payload)
201
292
  conns.append(conn)
293
+
294
+ missing = sorted(r for r in peers if r not in collected)
295
+ if missing:
296
+ _pair_for_punching(count=count, ranks=sorted(peers))
297
+ from ..transport import nat
298
+
299
+ for peer_rank in missing:
300
+ mapped = peers[peer_rank]["mapped"]
301
+ if mapped is None:
302
+ raise ReductionFailed(
303
+ f"replica {peer_rank} never connected directly and reported no "
304
+ "mapped address to punch to"
305
+ )
306
+ conn = nat.punch(
307
+ port, mapped, bytes.fromhex(token), initiator=False, start_at=start_at
308
+ )
309
+ conn.settimeout(_remaining(deadline))
310
+ peer_header, payload = _recv_message(conn)
311
+ collected[int(peer_header["rank"])] = _rebuild(tensor, payload)
312
+ conns.append(conn)
313
+
202
314
  arrays = [collected[r] for r in sorted(collected)]
203
315
  result = op(arrays)
204
316
  result_header, result_raw = _encode(result)
@@ -211,9 +323,34 @@ def _lead(
211
323
  conn.close()
212
324
 
213
325
 
214
- def _follow(leader_address: tuple, deadline: float, header: dict, raw: bytes) -> bytes:
215
- """Send this replica's array to the leader, and wait for the reduced result's bytes."""
216
- conn = socket.create_connection(leader_address, timeout=_remaining(deadline))
326
+ def _follow(
327
+ leader_rank: int,
328
+ peers: dict[int, dict],
329
+ deadline: float,
330
+ header: dict,
331
+ raw: bytes,
332
+ token: str,
333
+ start_at: float,
334
+ port: int,
335
+ count: int,
336
+ ) -> bytes:
337
+ """Send this replica's array to the leader, and wait for the reduced result's bytes.
338
+
339
+ Spec "Reducing between replicas": the address the leader reported for itself is
340
+ tried first, and the mapped address is punched to with ``initiator=True`` only when
341
+ that direct connection fails.
342
+ """
343
+ peer = peers[leader_rank]
344
+ try:
345
+ conn = socket.create_connection(peer["address"], timeout=_remaining(deadline))
346
+ except OSError:
347
+ _pair_for_punching(count=count, ranks=sorted(peers))
348
+ mapped = peer["mapped"]
349
+ if mapped is None:
350
+ raise
351
+ from ..transport import nat
352
+
353
+ conn = nat.punch(port, mapped, bytes.fromhex(token), initiator=True, start_at=start_at)
217
354
  try:
218
355
  conn.settimeout(_remaining(deadline))
219
356
  _send_message(conn, header, raw)
@@ -224,6 +361,15 @@ def _follow(leader_address: tuple, deadline: float, header: dict, raw: bytes) ->
224
361
  conn.close()
225
362
 
226
363
 
364
+ def _window() -> float:
365
+ """The punch window, from ``letify.transport.nat`` when it can be imported."""
366
+ try:
367
+ from ..transport import nat
368
+ except ImportError:
369
+ return 45.0
370
+ return nat.DEFAULT_WINDOW
371
+
372
+
227
373
  def _remaining(deadline: float) -> float:
228
374
  left = deadline - time.monotonic()
229
375
  if left <= 0:
@@ -102,6 +102,26 @@ STOP_WAIT = 5.0
102
102
  #: ``idle_timeout``.
103
103
  IDLE_TIMEOUT = 600
104
104
 
105
+ #: The sandbox's maximum lifetime, the longest Modal accepts, measured as accepted.
106
+ #: Spec "Modal": not a shorter number letify picked, for the reason The declaration gives
107
+ #: for ``timeout`` having no default. The previous 3600 s ended two sessions of a real
108
+ #: training run at one hour each with nothing saying why.
109
+ SANDBOX_TIMEOUT = 86400
110
+
111
+
112
+ def lifetime_detail(*, age_s: float, lifetime_s: int) -> str | None:
113
+ """What to say when a sandbox died at the lifetime letify asked for, or None.
114
+
115
+ Spec "Modal": the channel otherwise reports a worker that stopped without replying and
116
+ lists causes, none of which is the deadline letify set itself.
117
+ """
118
+ if lifetime_s <= 0 or age_s < lifetime_s - 60.0:
119
+ return None
120
+ return (
121
+ f"the Modal sandbox reached the {lifetime_s} s lifetime letify asked for; "
122
+ f"raise timeout on the account to run longer"
123
+ )
124
+
105
125
 
106
126
  class VolumePathMissing(RuntimeFailure):
107
127
  """The adapter answered that a volume path does not exist."""
@@ -377,6 +397,18 @@ class Modal(Provider):
377
397
  self._sandboxes: dict[str, str] = {}
378
398
  self._adapter: Adapter | None = None
379
399
 
400
+ @property
401
+ def sandbox_timeout_s(self) -> int:
402
+ """The sandbox's maximum lifetime, from the account or the longest Modal accepts."""
403
+ value = self.config.option("timeout", SANDBOX_TIMEOUT)
404
+ return max(1, int(value))
405
+
406
+ @property
407
+ def sandbox_idle_timeout_s(self) -> int:
408
+ """Seconds a sandbox may be idle before Modal terminates it. Spec "Modal"."""
409
+ value = self.config.option("idle_timeout", IDLE_TIMEOUT)
410
+ return max(1, int(value))
411
+
380
412
  def adapter(self) -> Adapter:
381
413
  """The adapter acting as this account, started on its first request."""
382
414
  if self._adapter is None or self._adapter.closed:
@@ -440,14 +472,15 @@ class Modal(Provider):
440
472
  args=["python3", "-u", "-c", BOOTSTRAP],
441
473
  packages=list(WORKER_PACKAGES),
442
474
  gpu=self.wire_name(runtime.instance) or None,
443
- timeout=int(self.config.option("timeout", 3600)),
444
- idle_timeout=int(self.config.option("idle_timeout", IDLE_TIMEOUT)),
475
+ timeout=self.sandbox_timeout_s,
476
+ idle_timeout=self.sandbox_idle_timeout_s,
445
477
  ports=[] if data_port is None else [data_port],
446
478
  )
447
479
  sandbox = str(created["sandbox"])
448
480
  self._sandboxes[runtime.name] = sandbox
449
481
  return SandboxChannel(
450
- adapter, sandbox, name=runtime.name, data_port=data_port, streams=streams
482
+ adapter, sandbox, name=runtime.name, data_port=data_port, streams=streams,
483
+ lifetime_s=self.sandbox_timeout_s,
451
484
  )
452
485
 
453
486
  def stop(self, runtime: Runtime) -> None:
@@ -598,11 +631,15 @@ class SandboxChannel(FramedChannel):
598
631
  name: str,
599
632
  data_port: int | None = None,
600
633
  streams: int = 1,
634
+ lifetime_s: int = 0,
601
635
  ):
602
636
  self.adapter = adapter
603
637
  self.sandbox = sandbox
604
638
  self.name = name
605
639
  self.data_port = data_port
640
+ #: The sandbox's maximum lifetime, so a death at it is named rather than guessed.
641
+ self.lifetime_s = lifetime_s
642
+ self.opened_at = time.monotonic()
606
643
  #: TCP connections the data channel carries its frames over.
607
644
  self.streams = streams
608
645
  self._sockets: list[socket.socket] = []
@@ -628,7 +665,13 @@ class SandboxChannel(FramedChannel):
628
665
  self._await_ready()
629
666
 
630
667
  def _raw_text(self) -> str:
631
- return bytes(self._raw[-2000:]).decode("utf-8", "replace")
668
+ text = bytes(self._raw[-2000:]).decode("utf-8", "replace")
669
+ # Spec "Modal": a sandbox that reached the lifetime letify asked for takes its
670
+ # worker with it, and the channel otherwise lists causes, none of them this one.
671
+ said = lifetime_detail(
672
+ age_s=time.monotonic() - self.opened_at, lifetime_s=self.lifetime_s
673
+ )
674
+ return f"{said}\n{text}" if said else text
632
675
 
633
676
  def _send_worker(self) -> None:
634
677
  # After a reexec the data connection is gone, so the source goes back over stdio.
@@ -10,6 +10,7 @@ claim N10.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import secrets
13
14
  import threading
14
15
  import time
15
16
  from typing import Any
@@ -24,6 +25,10 @@ _TOTAL_BYTES = 0
24
25
  #: Seconds between polls of a replica's joins, and between sweeps for an expired round.
25
26
  _POLL_S = 0.02
26
27
 
28
+ #: Seconds ahead of now a round's agreed start moment is set, so every replica's
29
+ #: answer arrives before dialing is meant to begin.
30
+ _START_AHEAD_S = 1.0
31
+
27
32
 
28
33
  def _account(payload: dict) -> None:
29
34
  """Add the size of one brokered payload to the running total."""
@@ -134,10 +139,26 @@ class Broker:
134
139
  self._fail(round_id, joins)
135
140
 
136
141
  def _answer(self, round_id: int, joins: dict[int, dict]) -> None:
137
- """Send every joined replica the peer table, naming the lowest rank leader."""
142
+ """Send every joined replica the peer table, naming the lowest rank leader.
143
+
144
+ Spec "Reducing between replicas": each entry carries both addresses a replica
145
+ joined with, and the table carries one token and one start moment, the same
146
+ for every replica of this round, so a pair can agree a punch without the client
147
+ relaying anything more than this.
148
+ """
138
149
  leader = min(joins)
139
- peers = {str(r): list(join["address"]) for r, join in joins.items()}
140
- payload = {"round": round_id, "peers": peers, "leader": leader, "count": len(joins)}
150
+ peers = {
151
+ str(r): {"address": list(join["address"]), "mapped": join.get("mapped")}
152
+ for r, join in joins.items()
153
+ }
154
+ payload = {
155
+ "round": round_id,
156
+ "peers": peers,
157
+ "leader": leader,
158
+ "count": len(joins),
159
+ "token": secrets.token_hex(16),
160
+ "start_at": time.time() + _START_AHEAD_S,
161
+ }
141
162
  self._send_to(joins, payload)
142
163
 
143
164
  def _fail(self, round_id: int, joins: dict[int, dict]) -> None:
@@ -174,7 +174,11 @@ class Runtime:
174
174
  # Not retried, so nothing else would end this session.
175
175
  self.shutdown(reason="interpreter_mismatch")
176
176
  raise
177
- if self.env_source == "sync" and not self.provider.persistent:
177
+ # The same rule as _environment_volumes: a project directory that does not
178
+ # survive the session is worth archiving whatever the provider calls itself.
179
+ if self.env_source == "sync" and not (
180
+ self.provider.persistent and self._project_survives()
181
+ ):
178
182
  from . import bootstrap
179
183
 
180
184
  root = bootstrap.project_dir(
@@ -643,8 +647,15 @@ class Runtime:
643
647
  self.workspace = self.eval(bootstrap.workspace_source(root), timeout=120)
644
648
 
645
649
  def _environment_volumes(self) -> tuple[Volume, ...]:
646
- """Explicit archive volumes, or the automatic cache for an ephemeral provider."""
647
- if self.provider.persistent:
650
+ """Explicit archive volumes, or the automatic cache where the project does not survive.
651
+
652
+ Spec "Volumes on a persistent runtime": a persistent provider needs no archive
653
+ because its project directory is on a disk that lasts, which holds only while that
654
+ directory is under the workspace root. One that keeps its workspace and builds the
655
+ .venv elsewhere archives like an ephemeral one, because the sync's output dies with
656
+ the session however durable the root is.
657
+ """
658
+ if self.provider.persistent and self._project_survives():
648
659
  return ()
649
660
  if self.volumes:
650
661
  return self.volumes
@@ -652,6 +663,14 @@ class Runtime:
652
663
 
653
664
  return (environment_volume(self.provider),)
654
665
 
666
+ def _project_survives(self) -> bool:
667
+ """Whether the directory the sync writes lives on the disk the workspace root is on."""
668
+ root = (self.provider.env_root or "").rstrip("/")
669
+ if not root:
670
+ return True
671
+ workspace = (self.workspace or self.provider.workspace_root or "").rstrip("/")
672
+ return bool(workspace) and (root == workspace or root.startswith(workspace + "/"))
673
+
655
674
  def _measured_rate(self) -> float | None:
656
675
  """This session's measured upload rate in bytes per second, or None where none was.
657
676
 
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.6.2"
141
+ version = "2.6.4"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.6.2"
149
+ version = "2.6.4"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.6.2"
167
+ version = "2.6.4"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.6.2"
11
+ version = "2.6.4"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.6.2",
9
+ "version": "2.6.4",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "devDependencies": {
12
12
  "@types/node": "^18.19.0",
@@ -2,7 +2,7 @@
2
2
  "name": "letify-status",
3
3
  "displayName": "letify Status",
4
4
  "description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
5
- "version": "2.6.2",
5
+ "version": "2.6.4",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.6.2"
3
+ version = "2.6.4"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"