letify 2.6.3__tar.gz → 2.6.5__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.3 → letify-2.6.5}/PKG-INFO +1 -1
  2. {letify-2.6.3 → letify-2.6.5}/docs/SPEC.md +18 -2
  3. {letify-2.6.3 → letify-2.6.5}/letify/__init__.py +1 -1
  4. {letify-2.6.3 → letify-2.6.5}/letify/providers/modal.py +47 -4
  5. {letify-2.6.3 → letify-2.6.5}/letify/runtime/session.py +33 -10
  6. {letify-2.6.3 → letify-2.6.5}/letify-core/Cargo.lock +3 -3
  7. {letify-2.6.3 → letify-2.6.5}/letify-core/Cargo.toml +1 -1
  8. {letify-2.6.3 → letify-2.6.5}/letify-ext/package-lock.json +2 -2
  9. {letify-2.6.3 → letify-2.6.5}/letify-ext/package.json +1 -1
  10. {letify-2.6.3 → letify-2.6.5}/pyproject.toml +1 -1
  11. {letify-2.6.3 → letify-2.6.5}/tests/test_providers.py +32 -1
  12. {letify-2.6.3 → letify-2.6.5}/tests/test_runtime.py +54 -0
  13. {letify-2.6.3 → letify-2.6.5}/.github/workflows/ci.yml +0 -0
  14. {letify-2.6.3 → letify-2.6.5}/.github/workflows/docs.yml +0 -0
  15. {letify-2.6.3 → letify-2.6.5}/.github/workflows/publish.yml +0 -0
  16. {letify-2.6.3 → letify-2.6.5}/.gitignore +0 -0
  17. {letify-2.6.3 → letify-2.6.5}/.researchtree +0 -0
  18. {letify-2.6.3 → letify-2.6.5}/CLAUDE.md +0 -0
  19. {letify-2.6.3 → letify-2.6.5}/LICENSE +0 -0
  20. {letify-2.6.3 → letify-2.6.5}/PROJECT.md +0 -0
  21. {letify-2.6.3 → letify-2.6.5}/README.md +0 -0
  22. {letify-2.6.3 → letify-2.6.5}/docs/COMPONENT.md +0 -0
  23. {letify-2.6.3 → letify-2.6.5}/docs/INTENT.md +0 -0
  24. {letify-2.6.3 → letify-2.6.5}/docs/NETWORK.md +0 -0
  25. {letify-2.6.3 → letify-2.6.5}/docs/guide/01-getting-started.md +0 -0
  26. {letify-2.6.3 → letify-2.6.5}/docs/guide/02-providers.md +0 -0
  27. {letify-2.6.3 → letify-2.6.5}/docs/guide/03-execution-modes.md +0 -0
  28. {letify-2.6.3 → letify-2.6.5}/docs/guide/04-environments-and-data.md +0 -0
  29. {letify-2.6.3 → letify-2.6.5}/docs/guide/05-concurrency.md +0 -0
  30. {letify-2.6.3 → letify-2.6.5}/docs/guide/06-cost.md +0 -0
  31. {letify-2.6.3 → letify-2.6.5}/docs/guide/07-troubleshooting.md +0 -0
  32. {letify-2.6.3 → letify-2.6.5}/docs/guide/08-releasing.md +0 -0
  33. {letify-2.6.3 → letify-2.6.5}/docs/guide/README.md +0 -0
  34. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  35. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  36. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  37. {letify-2.6.3 → letify-2.6.5}/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
  38. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  39. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  40. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  41. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  42. {letify-2.6.3 → letify-2.6.5}/docs/guide/ko/README.md +0 -0
  43. {letify-2.6.3 → letify-2.6.5}/docs/locales/README_ko.md +0 -0
  44. {letify-2.6.3 → letify-2.6.5}/docs/mkdocs.yml +0 -0
  45. {letify-2.6.3 → letify-2.6.5}/examples/README.md +0 -0
  46. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/.letify.example +0 -0
  47. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/00_smoke.py +0 -0
  48. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/01_sweep.py +0 -0
  49. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/02_resume.py +0 -0
  50. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/03_watch.py +0 -0
  51. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/README.md +0 -0
  52. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/README_ko.md +0 -0
  53. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/common.py +0 -0
  54. {letify-2.6.3 → letify-2.6.5}/examples/nvfp4-lora/recipe.py +0 -0
  55. {letify-2.6.3 → letify-2.6.5}/letify/cli.py +0 -0
  56. {letify-2.6.3 → letify-2.6.5}/letify/config/__init__.py +0 -0
  57. {letify-2.6.3 → letify-2.6.5}/letify/config/inventory.py +0 -0
  58. {letify-2.6.3 → letify-2.6.5}/letify/config/login.py +0 -0
  59. {letify-2.6.3 → letify-2.6.5}/letify/config/schema.py +0 -0
  60. {letify-2.6.3 → letify-2.6.5}/letify/config/secrets.py +0 -0
  61. {letify-2.6.3 → letify-2.6.5}/letify/config/writer.py +0 -0
  62. {letify-2.6.3 → letify-2.6.5}/letify/declare/__init__.py +0 -0
  63. {letify-2.6.3 → letify-2.6.5}/letify/declare/cache.py +0 -0
  64. {letify-2.6.3 → letify-2.6.5}/letify/declare/env.py +0 -0
  65. {letify-2.6.3 → letify-2.6.5}/letify/declare/function.py +0 -0
  66. {letify-2.6.3 → letify-2.6.5}/letify/declare/instance.py +0 -0
  67. {letify-2.6.3 → letify-2.6.5}/letify/declare/reduce.py +0 -0
  68. {letify-2.6.3 → letify-2.6.5}/letify/declare/replicas.py +0 -0
  69. {letify-2.6.3 → letify-2.6.5}/letify/errors.py +0 -0
  70. {letify-2.6.3 → letify-2.6.5}/letify/install.py +0 -0
  71. {letify-2.6.3 → letify-2.6.5}/letify/launcher.py +0 -0
  72. {letify-2.6.3 → letify-2.6.5}/letify/paths.py +0 -0
  73. {letify-2.6.3 → letify-2.6.5}/letify/protocol/__init__.py +0 -0
  74. {letify-2.6.3 → letify-2.6.5}/letify/protocol/codec.py +0 -0
  75. {letify-2.6.3 → letify-2.6.5}/letify/protocol/driver.py +0 -0
  76. {letify-2.6.3 → letify-2.6.5}/letify/protocol/handle.py +0 -0
  77. {letify-2.6.3 → letify-2.6.5}/letify/protocol/wire.py +0 -0
  78. {letify-2.6.3 → letify-2.6.5}/letify/protocol/worker.py +0 -0
  79. {letify-2.6.3 → letify-2.6.5}/letify/providers/__init__.py +0 -0
  80. {letify-2.6.3 → letify-2.6.5}/letify/providers/base.py +0 -0
  81. {letify-2.6.3 → letify-2.6.5}/letify/providers/colab.py +0 -0
  82. {letify-2.6.3 → letify-2.6.5}/letify/providers/colab_files.py +0 -0
  83. {letify-2.6.3 → letify-2.6.5}/letify/providers/colab_keepalive.py +0 -0
  84. {letify-2.6.3 → letify-2.6.5}/letify/providers/elice.py +0 -0
  85. {letify-2.6.3 → letify-2.6.5}/letify/providers/kaggle.py +0 -0
  86. {letify-2.6.3 → letify-2.6.5}/letify/providers/kaggle_adapter.py +0 -0
  87. {letify-2.6.3 → letify-2.6.5}/letify/providers/local.py +0 -0
  88. {letify-2.6.3 → letify-2.6.5}/letify/providers/modal_adapter.py +0 -0
  89. {letify-2.6.3 → letify-2.6.5}/letify/providers/naming.py +0 -0
  90. {letify-2.6.3 → letify-2.6.5}/letify/providers/shell.py +0 -0
  91. {letify-2.6.3 → letify-2.6.5}/letify/providers/tunnel.py +0 -0
  92. {letify-2.6.3 → letify-2.6.5}/letify/providers/usage.py +0 -0
  93. {letify-2.6.3 → letify-2.6.5}/letify/remoting/__init__.py +0 -0
  94. {letify-2.6.3 → letify-2.6.5}/letify/remoting/capability.py +0 -0
  95. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/__init__.py +0 -0
  96. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/client.py +0 -0
  97. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/cuda.py +0 -0
  98. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/executor.py +0 -0
  99. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/frames.py +0 -0
  100. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/guard.py +0 -0
  101. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/tensor.py +0 -0
  102. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/trace.py +0 -0
  103. {letify-2.6.3 → letify-2.6.5}/letify/remoting/device/value.py +0 -0
  104. {letify-2.6.3 → letify-2.6.5}/letify/remoting/loader.py +0 -0
  105. {letify-2.6.3 → letify-2.6.5}/letify/remoting/probe.py +0 -0
  106. {letify-2.6.3 → letify-2.6.5}/letify/render.py +0 -0
  107. {letify-2.6.3 → letify-2.6.5}/letify/runtime/__init__.py +0 -0
  108. {letify-2.6.3 → letify-2.6.5}/letify/runtime/bootstrap.py +0 -0
  109. {letify-2.6.3 → letify-2.6.5}/letify/runtime/broker.py +0 -0
  110. {letify-2.6.3 → letify-2.6.5}/letify/runtime/channel.py +0 -0
  111. {letify-2.6.3 → letify-2.6.5}/letify/runtime/lease.py +0 -0
  112. {letify-2.6.3 → letify-2.6.5}/letify/runtime/pool.py +0 -0
  113. {letify-2.6.3 → letify-2.6.5}/letify/runtime/telemetry.py +0 -0
  114. {letify-2.6.3 → letify-2.6.5}/letify/store/__init__.py +0 -0
  115. {letify-2.6.3 → letify-2.6.5}/letify/store/backends/__init__.py +0 -0
  116. {letify-2.6.3 → letify-2.6.5}/letify/store/backends/filesystem.py +0 -0
  117. {letify-2.6.3 → letify-2.6.5}/letify/store/backends/google_auth.py +0 -0
  118. {letify-2.6.3 → letify-2.6.5}/letify/store/backends/layout.py +0 -0
  119. {letify-2.6.3 → letify-2.6.5}/letify/store/backends/objects.py +0 -0
  120. {letify-2.6.3 → letify-2.6.5}/letify/store/cas.py +0 -0
  121. {letify-2.6.3 → letify-2.6.5}/letify/store/pathdata.py +0 -0
  122. {letify-2.6.3 → letify-2.6.5}/letify/store/sendorder.py +0 -0
  123. {letify-2.6.3 → letify-2.6.5}/letify/store/volume.py +0 -0
  124. {letify-2.6.3 → letify-2.6.5}/letify/stubs.py +0 -0
  125. {letify-2.6.3 → letify-2.6.5}/letify/tools.py +0 -0
  126. {letify-2.6.3 → letify-2.6.5}/letify/transport/__init__.py +0 -0
  127. {letify-2.6.3 → letify-2.6.5}/letify/transport/agent.py +0 -0
  128. {letify-2.6.3 → letify-2.6.5}/letify/transport/announce.py +0 -0
  129. {letify-2.6.3 → letify-2.6.5}/letify/transport/link.py +0 -0
  130. {letify-2.6.3 → letify-2.6.5}/letify/transport/nat.py +0 -0
  131. {letify-2.6.3 → letify-2.6.5}/letify/transport/pipeline.py +0 -0
  132. {letify-2.6.3 → letify-2.6.5}/letify/transport/portmap.py +0 -0
  133. {letify-2.6.3 → letify-2.6.5}/letify/transport/probe.py +0 -0
  134. {letify-2.6.3 → letify-2.6.5}/letify/transport/quic.py +0 -0
  135. {letify-2.6.3 → letify-2.6.5}/letify/transport/rendezvous.py +0 -0
  136. {letify-2.6.3 → letify-2.6.5}/letify/transport/setup.py +0 -0
  137. {letify-2.6.3 → letify-2.6.5}/letify/transport/sshopts.py +0 -0
  138. {letify-2.6.3 → letify-2.6.5}/letify/transport/strategies.py +0 -0
  139. {letify-2.6.3 → letify-2.6.5}/letify-core/.gitignore +0 -0
  140. {letify-2.6.3 → letify-2.6.5}/letify-core/build.py +0 -0
  141. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-agent/Cargo.toml +0 -0
  142. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-agent/src/driver.rs +0 -0
  143. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-agent/src/main.rs +0 -0
  144. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-driver/Cargo.toml +0 -0
  145. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-driver/src/client.rs +0 -0
  146. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-driver/src/exports.rs +0 -0
  147. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-driver/src/image.rs +0 -0
  148. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-driver/src/lib.rs +0 -0
  149. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-driver/src/status.rs +0 -0
  150. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-driver/src/table.rs +0 -0
  151. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-quic/Cargo.toml +0 -0
  152. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-quic/src/main.rs +0 -0
  153. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-wire/Cargo.toml +0 -0
  154. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-wire/src/lib.rs +0 -0
  155. {letify-2.6.3 → letify-2.6.5}/letify-core/letify-wire/tests/throughput.rs +0 -0
  156. {letify-2.6.3 → letify-2.6.5}/letify-core/rust-toolchain.toml +0 -0
  157. {letify-2.6.3 → letify-2.6.5}/letify-ext/.vscodeignore +0 -0
  158. {letify-2.6.3 → letify-2.6.5}/letify-ext/LICENSE +0 -0
  159. {letify-2.6.3 → letify-2.6.5}/letify-ext/README.md +0 -0
  160. {letify-2.6.3 → letify-2.6.5}/letify-ext/media/icon.svg +0 -0
  161. {letify-2.6.3 → letify-2.6.5}/letify-ext/src/cli.ts +0 -0
  162. {letify-2.6.3 → letify-2.6.5}/letify-ext/src/extension.ts +0 -0
  163. {letify-2.6.3 → letify-2.6.5}/letify-ext/src/history.ts +0 -0
  164. {letify-2.6.3 → letify-2.6.5}/letify-ext/src/model.ts +0 -0
  165. {letify-2.6.3 → letify-2.6.5}/letify-ext/src/poll.ts +0 -0
  166. {letify-2.6.3 → letify-2.6.5}/letify-ext/src/render.ts +0 -0
  167. {letify-2.6.3 → letify-2.6.5}/letify-ext/test/cli.test.ts +0 -0
  168. {letify-2.6.3 → letify-2.6.5}/letify-ext/test/manifest.test.ts +0 -0
  169. {letify-2.6.3 → letify-2.6.5}/letify-ext/test/model.test.ts +0 -0
  170. {letify-2.6.3 → letify-2.6.5}/letify-ext/test/regressions.test.ts +0 -0
  171. {letify-2.6.3 → letify-2.6.5}/letify-ext/tsconfig.json +0 -0
  172. {letify-2.6.3 → letify-2.6.5}/letify_providers.py +0 -0
  173. {letify-2.6.3 → letify-2.6.5}/scripts/sync-main.sh +0 -0
  174. {letify-2.6.3 → letify-2.6.5}/scripts/version.py +0 -0
  175. {letify-2.6.3 → letify-2.6.5}/tests/conftest.py +0 -0
  176. {letify-2.6.3 → letify-2.6.5}/tests/fake_kaggle_adapter.py +0 -0
  177. {letify-2.6.3 → letify-2.6.5}/tests/fake_modal_adapter.py +0 -0
  178. {letify-2.6.3 → letify-2.6.5}/tests/test_cli.py +0 -0
  179. {letify-2.6.3 → letify-2.6.5}/tests/test_cli_output.py +0 -0
  180. {letify-2.6.3 → letify-2.6.5}/tests/test_colab_keepalive.py +0 -0
  181. {letify-2.6.3 → letify-2.6.5}/tests/test_config.py +0 -0
  182. {letify-2.6.3 → letify-2.6.5}/tests/test_core.py +0 -0
  183. {letify-2.6.3 → letify-2.6.5}/tests/test_data.py +0 -0
  184. {letify-2.6.3 → letify-2.6.5}/tests/test_data_streaming.py +0 -0
  185. {letify-2.6.3 → letify-2.6.5}/tests/test_declare.py +0 -0
  186. {letify-2.6.3 → letify-2.6.5}/tests/test_device.py +0 -0
  187. {letify-2.6.3 → letify-2.6.5}/tests/test_distributed.py +0 -0
  188. {letify-2.6.3 → letify-2.6.5}/tests/test_editor_sessions.py +0 -0
  189. {letify-2.6.3 → letify-2.6.5}/tests/test_elice.py +0 -0
  190. {letify-2.6.3 → letify-2.6.5}/tests/test_frames.py +0 -0
  191. {letify-2.6.3 → letify-2.6.5}/tests/test_install.py +0 -0
  192. {letify-2.6.3 → letify-2.6.5}/tests/test_kaggle.py +0 -0
  193. {letify-2.6.3 → letify-2.6.5}/tests/test_kaggle_cookie.py +0 -0
  194. {letify-2.6.3 → letify-2.6.5}/tests/test_kaggle_link.py +0 -0
  195. {letify-2.6.3 → letify-2.6.5}/tests/test_link_log.py +0 -0
  196. {letify-2.6.3 → letify-2.6.5}/tests/test_notebook.py +0 -0
  197. {letify-2.6.3 → letify-2.6.5}/tests/test_parallel_placement.py +0 -0
  198. {letify-2.6.3 → letify-2.6.5}/tests/test_paths.py +0 -0
  199. {letify-2.6.3 → letify-2.6.5}/tests/test_portmap.py +0 -0
  200. {letify-2.6.3 → letify-2.6.5}/tests/test_protocol.py +0 -0
  201. {letify-2.6.3 → letify-2.6.5}/tests/test_quic.py +0 -0
  202. {letify-2.6.3 → letify-2.6.5}/tests/test_remoting.py +0 -0
  203. {letify-2.6.3 → letify-2.6.5}/tests/test_render.py +0 -0
  204. {letify-2.6.3 → letify-2.6.5}/tests/test_ship.py +0 -0
  205. {letify-2.6.3 → letify-2.6.5}/tests/test_store.py +0 -0
  206. {letify-2.6.3 → letify-2.6.5}/tests/test_strategies.py +0 -0
  207. {letify-2.6.3 → letify-2.6.5}/tests/test_stubs.py +0 -0
  208. {letify-2.6.3 → letify-2.6.5}/tests/test_transport.py +0 -0
  209. {letify-2.6.3 → letify-2.6.5}/tests/test_tunnel_setup.py +0 -0
  210. {letify-2.6.3 → letify-2.6.5}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.6.3
3
+ Version: 2.6.5
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
@@ -807,7 +807,11 @@ A `read_until` blocks until the sandbox prints a line, and the adapter answers o
807
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.
808
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.
809
809
 
810
- 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`.
811
815
 
812
816
  ### Modal data channel <!-- id: modal-data-channel -->
813
817
 
@@ -1059,6 +1063,16 @@ less time than the build did. The archive is smaller than the directory, so the
1059
1063
  overstates the transfer and errs toward building, which is the cheaper of the two to get wrong. The
1060
1064
  ref records the build seconds beside the archive.
1061
1065
 
1066
+ With no measured rate for the link, letify packs only when that directory is at most 1 GiB, and says
1067
+ why when it declines. Packing moves the archive as one payload that the worker and the client each
1068
+ hold whole, so a tree of several gigabytes is held twice in memory with nothing reporting progress:
1069
+ measured on a Modal A10, a 7.2 GB environment sat in that step for over 30 minutes while the session
1070
+ billed. Packing unconditionally was right while only an ephemeral provider packed, where the
1071
+ alternative was a certain full rebuild; a provider that keeps its workspace root and builds its venv
1072
+ elsewhere reaches the same branch, and there the alternative is a sync that may take two minutes.
1073
+ Making the pack stream rather than hold the archive whole is the fix that removes this limit, and
1074
+ until then the limit is what keeps the step from costing more than it saves.
1075
+
1062
1076
  A later session restores only when the archive's size over the link measured for that session would
1063
1077
  take less than the recorded build. Otherwise it syncs, and says why:
1064
1078
  `letify: <runtime>: syncing rather than restoring, because 3.0 GB over 13.6 MiB/s is 210 s against a
@@ -1072,7 +1086,9 @@ the kind this rule exists to catch, and the session that syncs instead both reco
1072
1086
  decides afresh whether the archive is worth keeping. The cost of that is one sync; the cost of
1073
1087
  trusting it is the transfer this rule was measured to avoid.
1074
1088
 
1075
- Skipping the pack is said too, because it is a decision letify made rather than a thing that did not
1089
+ Packing is said, because it is a step that can take minutes and had nothing reporting it:
1090
+ `letify: <runtime>: caching the environment, 2.1 GB`. Skipping it is said too, because it is a
1091
+ decision letify made rather than a thing that did not
1076
1092
  happen: `letify: <runtime>: not caching the environment, because 9.4 GB over 13.6 MiB/s is 659 s
1077
1093
  against a 34 s build`.
1078
1094
 
@@ -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.3"
69
+ __version__ = "2.6.5"
70
70
 
71
71
 
72
72
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -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.
@@ -47,6 +47,11 @@ if TYPE_CHECKING:
47
47
  #: The total size of argument blob files kept under a persistent workspace root, 32 GiB.
48
48
  BLOB_DISK_LIMIT = 32 * 1024**3
49
49
 
50
+ #: The largest project directory letify packs into an environment archive when it has no
51
+ #: measured rate to judge the transfer by. Spec "Materializing into a runtime": the pack
52
+ #: moves the archive as one payload the worker and the client each hold whole.
53
+ PACK_WITHOUT_A_RATE = 1024**3
54
+
50
55
 
51
56
  def _against_the_build(size: int, rate: float, build_s: float) -> str:
52
57
  """The three numbers both cache decisions weigh, as they are reported.
@@ -185,6 +190,11 @@ class Runtime:
185
190
  self.provider.env_root or self.workspace or self.provider.workspace_root, self.env
186
191
  )
187
192
  if self._worth_packing(root):
193
+ size = self._tree_size(root)
194
+ self._say(
195
+ "caching the environment"
196
+ + (f", {size / 1_000_000_000:.1f} GB" if size else "")
197
+ )
188
198
  self._environment_volumes()[0].cache_env_from(
189
199
  self, self.env, root, platform=self.platform, build_s=self.env_build_s
190
200
  )
@@ -687,25 +697,38 @@ class Runtime:
687
697
  return None
688
698
  return float(rate)
689
699
 
700
+ def _tree_size(self, root: str) -> int | None:
701
+ """The byte size of the project directory on the runtime, or None when unknown."""
702
+ from . import bootstrap
703
+
704
+ try:
705
+ size = self.eval(bootstrap.tree_size_source(root), timeout=120)
706
+ except Exception:
707
+ return None
708
+ return size if isinstance(size, int) and size > 0 else None
709
+
690
710
  def _worth_packing(self, root: str) -> bool:
691
711
  """Whether moving the project directory over this session's link beats the build.
692
712
 
693
713
  Spec "Materializing into a runtime": the directory is larger than the archive it
694
- would become, so this overstates the transfer and errs toward building. A session
695
- with no measured rate packs unconditionally, as it always did.
714
+ would become, so this overstates the transfer and errs toward building. With no
715
+ measured rate it packs only what letify is willing to hold in memory, because the
716
+ pack moves the archive as one payload the worker and the client each hold whole.
696
717
  """
697
718
  if self.env_build_s is None:
698
719
  return True
699
720
  rate = self._measured_rate()
721
+ size = self._tree_size(root)
700
722
  if rate is None:
701
- return True
702
- from . import bootstrap
703
-
704
- try:
705
- size = self.eval(bootstrap.tree_size_source(root), timeout=120)
706
- except Exception:
707
- return True
708
- if not isinstance(size, int) or size <= 0:
723
+ if size is None or size <= PACK_WITHOUT_A_RATE:
724
+ return True
725
+ self._say(
726
+ f"not caching the environment, because {size / 1_000_000_000:.1f} GB is over "
727
+ f"the {PACK_WITHOUT_A_RATE / 1024**3:.1f} GiB letify will hold in memory and "
728
+ f"this link was never measured"
729
+ )
730
+ return False
731
+ if size is None:
709
732
  return True
710
733
  if (size / rate) < self.env_build_s:
711
734
  return True
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.6.3"
141
+ version = "2.6.5"
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.3"
149
+ version = "2.6.5"
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.3"
167
+ version = "2.6.5"
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.3"
11
+ version = "2.6.5"
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.3",
3
+ "version": "2.6.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.6.3",
9
+ "version": "2.6.5",
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.3",
5
+ "version": "2.6.5",
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.3"
3
+ version = "2.6.5"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2218,7 +2218,9 @@ def test_a_modal_sandbox_is_created_with_a_lifetime_and_an_idle_limit(
2218
2218
  provider.open_channel(runtime)
2219
2219
  try:
2220
2220
  [created] = fake_modal.requests("create")
2221
- assert created["timeout"] == 3600
2221
+ # Spec "Modal": the lifetime is the longest Modal accepts, not an hour letify
2222
+ # picked, because a deadline letify invented ends the user's work at its guess.
2223
+ assert created["timeout"] == 86400
2222
2224
  assert created["idle_timeout"] == 600
2223
2225
  finally:
2224
2226
  provider.stop(runtime)
@@ -2910,3 +2912,32 @@ def test_colab_punch_rendezvous_completes_during_tailcat_installation(
2910
2912
  release.set()
2911
2913
  installing.join(2)
2912
2914
  assert endpoints == [("fake", 22)]
2915
+
2916
+
2917
+ def test_a_modal_sandbox_is_not_given_an_hour_long_deadline(config_file) -> None:
2918
+ """Spec "Modal": a deadline letify invented would end a two hour run at its guess.
2919
+
2920
+ The default was 3600 s, exactly one hour, and it ended two Modal sessions of a real
2921
+ training run at one hour each with nothing saying why. The declaration's own `timeout`
2922
+ has no default for this reason, and the sandbox's lifetime is the same question.
2923
+ """
2924
+ from conftest import provider_of
2925
+
2926
+ from letify.providers import Modal
2927
+
2928
+ provider = provider_of(Modal, "lab")
2929
+ assert provider.sandbox_timeout_s == 86400, "letify picked a deadline again"
2930
+ # An idle sandbox is what has to be bounded, and that bound is unchanged.
2931
+ assert provider.sandbox_idle_timeout_s == 600
2932
+ # An account that wants a cap still sets one.
2933
+ assert provider_of(Modal, "lab", timeout=7200).sandbox_timeout_s == 7200
2934
+
2935
+
2936
+ def test_a_sandbox_that_reached_its_lifetime_says_so(config_file) -> None:
2937
+ """Spec "Modal": the channel otherwise lists causes, none of them this one."""
2938
+ from letify.providers.modal import lifetime_detail
2939
+
2940
+ assert lifetime_detail(age_s=3599.5, lifetime_s=3600) is not None
2941
+ assert "3600" in lifetime_detail(age_s=3599.5, lifetime_s=3600)
2942
+ assert "raise timeout" in lifetime_detail(age_s=3601.0, lifetime_s=3600)
2943
+ assert lifetime_detail(age_s=120.0, lifetime_s=3600) is None
@@ -2593,3 +2593,57 @@ def test_a_persistent_provider_that_builds_its_venv_elsewhere_still_archives(
2593
2593
  finally:
2594
2594
  runtime.shutdown()
2595
2595
  assert volume.cached_env(env, platform), "nothing was archived, so the next session resyncs"
2596
+
2597
+
2598
+ def test_a_large_environment_is_not_packed_when_the_link_was_never_measured(
2599
+ uv_project: Path, tmp_path: Path, monkeypatch, capsys
2600
+ ) -> None:
2601
+ """Spec "Materializing into a runtime": the pack holds the archive whole, twice.
2602
+
2603
+ 2.6.3 made a provider that keeps its workspace root archive like an ephemeral one, and
2604
+ that branch packed unconditionally with no measured rate. On a Modal A10 a 7.2 GB
2605
+ environment sat in the pack for over 30 minutes, silently, while the session billed.
2606
+ """
2607
+ from letify.runtime import session as sessionmod
2608
+
2609
+ provider = provider_of(PreparingLocal, "lab", persistent=False)
2610
+ volume = provider.volume(
2611
+ "cache", backend="filesystem", root=str(tmp_path / "store"), mount=str(tmp_path / "mount")
2612
+ )
2613
+ env = Env()
2614
+ instance = Instance(provider, gpu=None)._placed("remote")
2615
+ # No measured rate, as a Modal sandbox has none, and a tree over the limit.
2616
+ monkeypatch.setattr(sessionmod.Runtime, "_measured_rate", lambda self: None)
2617
+ monkeypatch.setattr(
2618
+ sessionmod.Runtime, "_tree_size", lambda self, root: 8 * 1024**3
2619
+ )
2620
+ runtime = provider.start(instance, env, name="lab-1", volumes=(volume,))
2621
+ try:
2622
+ platform = runtime.platform
2623
+ finally:
2624
+ runtime.shutdown()
2625
+ assert volume.cached_env(env, platform) is None
2626
+ err = capsys.readouterr().err
2627
+ assert "over the 1.0 GiB letify will hold in memory" in err, err
2628
+
2629
+
2630
+ def test_a_small_environment_is_still_packed_and_says_so(
2631
+ uv_project: Path, tmp_path: Path, monkeypatch, capsys
2632
+ ) -> None:
2633
+ """Spec "Materializing into a runtime": the pack can take minutes, so it is said."""
2634
+ from letify.runtime import session as sessionmod
2635
+
2636
+ provider = provider_of(PreparingLocal, "lab", persistent=False)
2637
+ volume = provider.volume(
2638
+ "cache", backend="filesystem", root=str(tmp_path / "store"), mount=str(tmp_path / "mount")
2639
+ )
2640
+ env = Env()
2641
+ instance = Instance(provider, gpu=None)._placed("remote")
2642
+ monkeypatch.setattr(sessionmod.Runtime, "_measured_rate", lambda self: None)
2643
+ runtime = provider.start(instance, env, name="lab-1", volumes=(volume,))
2644
+ try:
2645
+ platform = runtime.platform
2646
+ finally:
2647
+ runtime.shutdown()
2648
+ assert volume.cached_env(env, platform)
2649
+ assert "caching the environment" in capsys.readouterr().err
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes