letify 2.8.1__tar.gz → 2.8.3__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 (214) hide show
  1. {letify-2.8.1 → letify-2.8.3}/PKG-INFO +1 -1
  2. {letify-2.8.1 → letify-2.8.3}/docs/SPEC.md +20 -3
  3. {letify-2.8.1 → letify-2.8.3}/letify/__init__.py +1 -1
  4. {letify-2.8.1 → letify-2.8.3}/letify/protocol/worker.py +102 -1
  5. {letify-2.8.1 → letify-2.8.3}/letify/providers/colab.py +39 -5
  6. {letify-2.8.1 → letify-2.8.3}/letify/runtime/bootstrap.py +4 -2
  7. {letify-2.8.1 → letify-2.8.3}/letify/runtime/session.py +193 -53
  8. {letify-2.8.1 → letify-2.8.3}/letify/store/volume.py +16 -9
  9. {letify-2.8.1 → letify-2.8.3}/letify/transport/nat.py +15 -1
  10. {letify-2.8.1 → letify-2.8.3}/letify/transport/strategies.py +101 -15
  11. {letify-2.8.1 → letify-2.8.3}/letify-core/Cargo.lock +3 -3
  12. {letify-2.8.1 → letify-2.8.3}/letify-core/Cargo.toml +1 -1
  13. {letify-2.8.1 → letify-2.8.3}/letify-ext/package-lock.json +2 -2
  14. {letify-2.8.1 → letify-2.8.3}/letify-ext/package.json +1 -1
  15. {letify-2.8.1 → letify-2.8.3}/pyproject.toml +1 -1
  16. {letify-2.8.1 → letify-2.8.3}/tests/conftest.py +5 -1
  17. {letify-2.8.1 → letify-2.8.3}/tests/test_drive_store.py +134 -1
  18. {letify-2.8.1 → letify-2.8.3}/tests/test_providers.py +72 -3
  19. {letify-2.8.1 → letify-2.8.3}/tests/test_runtime.py +75 -0
  20. {letify-2.8.1 → letify-2.8.3}/tests/test_strategies.py +125 -0
  21. {letify-2.8.1 → letify-2.8.3}/.github/workflows/ci.yml +0 -0
  22. {letify-2.8.1 → letify-2.8.3}/.github/workflows/docs.yml +0 -0
  23. {letify-2.8.1 → letify-2.8.3}/.github/workflows/publish.yml +0 -0
  24. {letify-2.8.1 → letify-2.8.3}/.gitignore +0 -0
  25. {letify-2.8.1 → letify-2.8.3}/.researchtree +0 -0
  26. {letify-2.8.1 → letify-2.8.3}/CLAUDE.md +0 -0
  27. {letify-2.8.1 → letify-2.8.3}/LICENSE +0 -0
  28. {letify-2.8.1 → letify-2.8.3}/PROJECT.md +0 -0
  29. {letify-2.8.1 → letify-2.8.3}/README.md +0 -0
  30. {letify-2.8.1 → letify-2.8.3}/docs/COMPONENT.md +0 -0
  31. {letify-2.8.1 → letify-2.8.3}/docs/INTENT.md +0 -0
  32. {letify-2.8.1 → letify-2.8.3}/docs/NETWORK.md +0 -0
  33. {letify-2.8.1 → letify-2.8.3}/docs/guide/01-getting-started.md +0 -0
  34. {letify-2.8.1 → letify-2.8.3}/docs/guide/02-providers.md +0 -0
  35. {letify-2.8.1 → letify-2.8.3}/docs/guide/03-execution-modes.md +0 -0
  36. {letify-2.8.1 → letify-2.8.3}/docs/guide/04-environments-and-data.md +0 -0
  37. {letify-2.8.1 → letify-2.8.3}/docs/guide/05-concurrency.md +0 -0
  38. {letify-2.8.1 → letify-2.8.3}/docs/guide/06-cost.md +0 -0
  39. {letify-2.8.1 → letify-2.8.3}/docs/guide/07-troubleshooting.md +0 -0
  40. {letify-2.8.1 → letify-2.8.3}/docs/guide/08-releasing.md +0 -0
  41. {letify-2.8.1 → letify-2.8.3}/docs/guide/README.md +0 -0
  42. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  43. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  44. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  45. {letify-2.8.1 → letify-2.8.3}/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
  46. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  47. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  48. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  49. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  50. {letify-2.8.1 → letify-2.8.3}/docs/guide/ko/README.md +0 -0
  51. {letify-2.8.1 → letify-2.8.3}/docs/locales/README_ko.md +0 -0
  52. {letify-2.8.1 → letify-2.8.3}/docs/mkdocs.yml +0 -0
  53. {letify-2.8.1 → letify-2.8.3}/examples/README.md +0 -0
  54. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/.letify.example +0 -0
  55. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/00_smoke.py +0 -0
  56. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/01_sweep.py +0 -0
  57. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/02_resume.py +0 -0
  58. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/03_watch.py +0 -0
  59. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/README.md +0 -0
  60. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/README_ko.md +0 -0
  61. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/common.py +0 -0
  62. {letify-2.8.1 → letify-2.8.3}/examples/nvfp4-lora/recipe.py +0 -0
  63. {letify-2.8.1 → letify-2.8.3}/letify/cli.py +0 -0
  64. {letify-2.8.1 → letify-2.8.3}/letify/config/__init__.py +0 -0
  65. {letify-2.8.1 → letify-2.8.3}/letify/config/inventory.py +0 -0
  66. {letify-2.8.1 → letify-2.8.3}/letify/config/login.py +0 -0
  67. {letify-2.8.1 → letify-2.8.3}/letify/config/schema.py +0 -0
  68. {letify-2.8.1 → letify-2.8.3}/letify/config/secrets.py +0 -0
  69. {letify-2.8.1 → letify-2.8.3}/letify/config/writer.py +0 -0
  70. {letify-2.8.1 → letify-2.8.3}/letify/declare/__init__.py +0 -0
  71. {letify-2.8.1 → letify-2.8.3}/letify/declare/cache.py +0 -0
  72. {letify-2.8.1 → letify-2.8.3}/letify/declare/env.py +0 -0
  73. {letify-2.8.1 → letify-2.8.3}/letify/declare/function.py +0 -0
  74. {letify-2.8.1 → letify-2.8.3}/letify/declare/instance.py +0 -0
  75. {letify-2.8.1 → letify-2.8.3}/letify/declare/reduce.py +0 -0
  76. {letify-2.8.1 → letify-2.8.3}/letify/declare/replicas.py +0 -0
  77. {letify-2.8.1 → letify-2.8.3}/letify/errors.py +0 -0
  78. {letify-2.8.1 → letify-2.8.3}/letify/install.py +0 -0
  79. {letify-2.8.1 → letify-2.8.3}/letify/launcher.py +0 -0
  80. {letify-2.8.1 → letify-2.8.3}/letify/paths.py +0 -0
  81. {letify-2.8.1 → letify-2.8.3}/letify/protocol/__init__.py +0 -0
  82. {letify-2.8.1 → letify-2.8.3}/letify/protocol/codec.py +0 -0
  83. {letify-2.8.1 → letify-2.8.3}/letify/protocol/driver.py +0 -0
  84. {letify-2.8.1 → letify-2.8.3}/letify/protocol/handle.py +0 -0
  85. {letify-2.8.1 → letify-2.8.3}/letify/protocol/wire.py +0 -0
  86. {letify-2.8.1 → letify-2.8.3}/letify/providers/__init__.py +0 -0
  87. {letify-2.8.1 → letify-2.8.3}/letify/providers/base.py +0 -0
  88. {letify-2.8.1 → letify-2.8.3}/letify/providers/colab_files.py +0 -0
  89. {letify-2.8.1 → letify-2.8.3}/letify/providers/colab_keepalive.py +0 -0
  90. {letify-2.8.1 → letify-2.8.3}/letify/providers/elice.py +0 -0
  91. {letify-2.8.1 → letify-2.8.3}/letify/providers/kaggle.py +0 -0
  92. {letify-2.8.1 → letify-2.8.3}/letify/providers/kaggle_adapter.py +0 -0
  93. {letify-2.8.1 → letify-2.8.3}/letify/providers/local.py +0 -0
  94. {letify-2.8.1 → letify-2.8.3}/letify/providers/modal.py +0 -0
  95. {letify-2.8.1 → letify-2.8.3}/letify/providers/modal_adapter.py +0 -0
  96. {letify-2.8.1 → letify-2.8.3}/letify/providers/naming.py +0 -0
  97. {letify-2.8.1 → letify-2.8.3}/letify/providers/shell.py +0 -0
  98. {letify-2.8.1 → letify-2.8.3}/letify/providers/tunnel.py +0 -0
  99. {letify-2.8.1 → letify-2.8.3}/letify/providers/usage.py +0 -0
  100. {letify-2.8.1 → letify-2.8.3}/letify/remoting/__init__.py +0 -0
  101. {letify-2.8.1 → letify-2.8.3}/letify/remoting/capability.py +0 -0
  102. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/__init__.py +0 -0
  103. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/client.py +0 -0
  104. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/cuda.py +0 -0
  105. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/executor.py +0 -0
  106. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/frames.py +0 -0
  107. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/guard.py +0 -0
  108. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/tensor.py +0 -0
  109. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/trace.py +0 -0
  110. {letify-2.8.1 → letify-2.8.3}/letify/remoting/device/value.py +0 -0
  111. {letify-2.8.1 → letify-2.8.3}/letify/remoting/loader.py +0 -0
  112. {letify-2.8.1 → letify-2.8.3}/letify/remoting/probe.py +0 -0
  113. {letify-2.8.1 → letify-2.8.3}/letify/render.py +0 -0
  114. {letify-2.8.1 → letify-2.8.3}/letify/runtime/__init__.py +0 -0
  115. {letify-2.8.1 → letify-2.8.3}/letify/runtime/broker.py +0 -0
  116. {letify-2.8.1 → letify-2.8.3}/letify/runtime/channel.py +0 -0
  117. {letify-2.8.1 → letify-2.8.3}/letify/runtime/lease.py +0 -0
  118. {letify-2.8.1 → letify-2.8.3}/letify/runtime/pool.py +0 -0
  119. {letify-2.8.1 → letify-2.8.3}/letify/runtime/telemetry.py +0 -0
  120. {letify-2.8.1 → letify-2.8.3}/letify/store/__init__.py +0 -0
  121. {letify-2.8.1 → letify-2.8.3}/letify/store/backends/__init__.py +0 -0
  122. {letify-2.8.1 → letify-2.8.3}/letify/store/backends/drive.py +0 -0
  123. {letify-2.8.1 → letify-2.8.3}/letify/store/backends/filesystem.py +0 -0
  124. {letify-2.8.1 → letify-2.8.3}/letify/store/backends/google_auth.py +0 -0
  125. {letify-2.8.1 → letify-2.8.3}/letify/store/backends/layout.py +0 -0
  126. {letify-2.8.1 → letify-2.8.3}/letify/store/backends/objects.py +0 -0
  127. {letify-2.8.1 → letify-2.8.3}/letify/store/cas.py +0 -0
  128. {letify-2.8.1 → letify-2.8.3}/letify/store/handoff.py +0 -0
  129. {letify-2.8.1 → letify-2.8.3}/letify/store/pathdata.py +0 -0
  130. {letify-2.8.1 → letify-2.8.3}/letify/store/sendorder.py +0 -0
  131. {letify-2.8.1 → letify-2.8.3}/letify/stubs.py +0 -0
  132. {letify-2.8.1 → letify-2.8.3}/letify/tools.py +0 -0
  133. {letify-2.8.1 → letify-2.8.3}/letify/transport/__init__.py +0 -0
  134. {letify-2.8.1 → letify-2.8.3}/letify/transport/agent.py +0 -0
  135. {letify-2.8.1 → letify-2.8.3}/letify/transport/announce.py +0 -0
  136. {letify-2.8.1 → letify-2.8.3}/letify/transport/link.py +0 -0
  137. {letify-2.8.1 → letify-2.8.3}/letify/transport/pipeline.py +0 -0
  138. {letify-2.8.1 → letify-2.8.3}/letify/transport/portmap.py +0 -0
  139. {letify-2.8.1 → letify-2.8.3}/letify/transport/probe.py +0 -0
  140. {letify-2.8.1 → letify-2.8.3}/letify/transport/quic.py +0 -0
  141. {letify-2.8.1 → letify-2.8.3}/letify/transport/rendezvous.py +0 -0
  142. {letify-2.8.1 → letify-2.8.3}/letify/transport/setup.py +0 -0
  143. {letify-2.8.1 → letify-2.8.3}/letify/transport/sshopts.py +0 -0
  144. {letify-2.8.1 → letify-2.8.3}/letify-core/.gitignore +0 -0
  145. {letify-2.8.1 → letify-2.8.3}/letify-core/build.py +0 -0
  146. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-agent/Cargo.toml +0 -0
  147. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-agent/src/driver.rs +0 -0
  148. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-agent/src/main.rs +0 -0
  149. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/Cargo.toml +0 -0
  150. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/client.rs +0 -0
  151. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/exports.rs +0 -0
  152. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/image.rs +0 -0
  153. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/lib.rs +0 -0
  154. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/status.rs +0 -0
  155. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-driver/src/table.rs +0 -0
  156. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-quic/Cargo.toml +0 -0
  157. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-quic/src/main.rs +0 -0
  158. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-wire/Cargo.toml +0 -0
  159. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-wire/src/lib.rs +0 -0
  160. {letify-2.8.1 → letify-2.8.3}/letify-core/letify-wire/tests/throughput.rs +0 -0
  161. {letify-2.8.1 → letify-2.8.3}/letify-core/rust-toolchain.toml +0 -0
  162. {letify-2.8.1 → letify-2.8.3}/letify-ext/.vscodeignore +0 -0
  163. {letify-2.8.1 → letify-2.8.3}/letify-ext/LICENSE +0 -0
  164. {letify-2.8.1 → letify-2.8.3}/letify-ext/README.md +0 -0
  165. {letify-2.8.1 → letify-2.8.3}/letify-ext/media/icon.svg +0 -0
  166. {letify-2.8.1 → letify-2.8.3}/letify-ext/src/cli.ts +0 -0
  167. {letify-2.8.1 → letify-2.8.3}/letify-ext/src/extension.ts +0 -0
  168. {letify-2.8.1 → letify-2.8.3}/letify-ext/src/history.ts +0 -0
  169. {letify-2.8.1 → letify-2.8.3}/letify-ext/src/model.ts +0 -0
  170. {letify-2.8.1 → letify-2.8.3}/letify-ext/src/poll.ts +0 -0
  171. {letify-2.8.1 → letify-2.8.3}/letify-ext/src/render.ts +0 -0
  172. {letify-2.8.1 → letify-2.8.3}/letify-ext/test/cli.test.ts +0 -0
  173. {letify-2.8.1 → letify-2.8.3}/letify-ext/test/manifest.test.ts +0 -0
  174. {letify-2.8.1 → letify-2.8.3}/letify-ext/test/model.test.ts +0 -0
  175. {letify-2.8.1 → letify-2.8.3}/letify-ext/test/regressions.test.ts +0 -0
  176. {letify-2.8.1 → letify-2.8.3}/letify-ext/tsconfig.json +0 -0
  177. {letify-2.8.1 → letify-2.8.3}/letify_providers.py +0 -0
  178. {letify-2.8.1 → letify-2.8.3}/scripts/sync-main.sh +0 -0
  179. {letify-2.8.1 → letify-2.8.3}/scripts/version.py +0 -0
  180. {letify-2.8.1 → letify-2.8.3}/tests/fake_kaggle_adapter.py +0 -0
  181. {letify-2.8.1 → letify-2.8.3}/tests/fake_modal_adapter.py +0 -0
  182. {letify-2.8.1 → letify-2.8.3}/tests/test_cli.py +0 -0
  183. {letify-2.8.1 → letify-2.8.3}/tests/test_cli_output.py +0 -0
  184. {letify-2.8.1 → letify-2.8.3}/tests/test_colab_keepalive.py +0 -0
  185. {letify-2.8.1 → letify-2.8.3}/tests/test_config.py +0 -0
  186. {letify-2.8.1 → letify-2.8.3}/tests/test_core.py +0 -0
  187. {letify-2.8.1 → letify-2.8.3}/tests/test_data.py +0 -0
  188. {letify-2.8.1 → letify-2.8.3}/tests/test_data_streaming.py +0 -0
  189. {letify-2.8.1 → letify-2.8.3}/tests/test_declare.py +0 -0
  190. {letify-2.8.1 → letify-2.8.3}/tests/test_device.py +0 -0
  191. {letify-2.8.1 → letify-2.8.3}/tests/test_distributed.py +0 -0
  192. {letify-2.8.1 → letify-2.8.3}/tests/test_drive_handoff.py +0 -0
  193. {letify-2.8.1 → letify-2.8.3}/tests/test_editor_sessions.py +0 -0
  194. {letify-2.8.1 → letify-2.8.3}/tests/test_elice.py +0 -0
  195. {letify-2.8.1 → letify-2.8.3}/tests/test_frames.py +0 -0
  196. {letify-2.8.1 → letify-2.8.3}/tests/test_install.py +0 -0
  197. {letify-2.8.1 → letify-2.8.3}/tests/test_kaggle.py +0 -0
  198. {letify-2.8.1 → letify-2.8.3}/tests/test_kaggle_cookie.py +0 -0
  199. {letify-2.8.1 → letify-2.8.3}/tests/test_kaggle_link.py +0 -0
  200. {letify-2.8.1 → letify-2.8.3}/tests/test_link_log.py +0 -0
  201. {letify-2.8.1 → letify-2.8.3}/tests/test_notebook.py +0 -0
  202. {letify-2.8.1 → letify-2.8.3}/tests/test_parallel_placement.py +0 -0
  203. {letify-2.8.1 → letify-2.8.3}/tests/test_paths.py +0 -0
  204. {letify-2.8.1 → letify-2.8.3}/tests/test_portmap.py +0 -0
  205. {letify-2.8.1 → letify-2.8.3}/tests/test_protocol.py +0 -0
  206. {letify-2.8.1 → letify-2.8.3}/tests/test_quic.py +0 -0
  207. {letify-2.8.1 → letify-2.8.3}/tests/test_remoting.py +0 -0
  208. {letify-2.8.1 → letify-2.8.3}/tests/test_render.py +0 -0
  209. {letify-2.8.1 → letify-2.8.3}/tests/test_ship.py +0 -0
  210. {letify-2.8.1 → letify-2.8.3}/tests/test_store.py +0 -0
  211. {letify-2.8.1 → letify-2.8.3}/tests/test_stubs.py +0 -0
  212. {letify-2.8.1 → letify-2.8.3}/tests/test_transport.py +0 -0
  213. {letify-2.8.1 → letify-2.8.3}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.8.1 → letify-2.8.3}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.8.1
3
+ Version: 2.8.3
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
@@ -1162,7 +1162,7 @@ The archive is `<workspace root>/env-archives/<env key>-<platform>.tar`, uncompr
1162
1162
 
1163
1163
  The client's inbound link is the weak one, so the store path is slower than the build in both directions. Compression costs more than it saves on the volume, so the archive is not compressed.
1164
1164
 
1165
- After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
1165
+ After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. The temporary name carries 16 random hex digits, `<archive>.<hex>.partial`, not the process id, because every Modal sandbox runs its worker under the same small pid, so two sessions packing the same environment at once wrote one file and one of them failed with tar exit status 2. A pack that fails for any reason removes its temporary file, says `letify: <runtime>: not caching the environment on the workspace root (<reason>)`, and the session goes on: the environment is already built and checked, so a failed cache never fails the session or its call. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
1166
1166
 
1167
1167
  At the start of `install_env`, when that archive exists on the runtime, the runtime says `restoring the environment from the workspace root`, extracts it into the project directory's parent, and validates the interpreter with the same check an archive restore uses. The restore refuses a member whose path escapes the destination and allows the absolute symlinks a `.venv` needs. A directory an earlier session left under the same name is removed first. When validation fails, the runtime removes the extracted tree, syncs, and replaces the archive. `Runtime.env_source` is `"workspace"` after this restore. This path is taken before and instead of the store path, and such a provider never uses the client store or the bucket store for its environment.
1168
1168
 
@@ -1275,7 +1275,9 @@ Letify keeps its files in a folder named `letify` at the root of that Drive, one
1275
1275
 
1276
1276
  An ephemeral provider uses the Drive store for its environment archive when the machine declares one and the account sets no `bucket`, which keeps precedence. A provider whose environment survives the session on its own disk or volume does not use it. An archive on Drive is an uncompressed tar, because compression was most of the cost: on a Kaggle runtime, packing a 5.5 GB environment as tar.gz and uploading it took about 12 minutes, against an upload of roughly 77 s for the uncompressed tree at the measured rate.
1277
1277
 
1278
- Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took longer than the archive would take to restore, estimated as the project directory's size over 176 MiB/s, the slower Drive download measured, plus the same size over 170 MB/s, the extraction rate measured on a sandbox disk. The record beside the archive keeps the build seconds and, once a session has restored from it, the restore seconds that session measured; a later session restores only while the measured restore is shorter than the measured build, and syncs otherwise, saying `letify: <runtime>: syncing rather than restoring from Drive, because a restore took <s> s against a <s> s build`.
1278
+ Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took at least twice as long as the archive would take to restore, estimated as the project directory's size over 176 MiB/s, the slower Drive download measured, plus the same size over 170 MB/s, the extraction rate measured on a sandbox disk. The record beside the archive keeps the build seconds and, once a session has restored from it, the restore seconds that session measured; a later session restores only while the measured restore is shorter than the measured build, and syncs otherwise, saying `letify: <runtime>: syncing rather than restoring from Drive, because a restore took <s> s against a <s> s build`. The factor of two is there because packing has a cost of its own that the comparison leaves out: on a Kaggle runtime a 7.2 GB environment that built in 87.6 s against an estimated 81 s restore was packed, which would save about 6 s a session at the price of the pack.
1279
+
1280
+ The pack to Drive runs on the worker beside the session's calls, not before them. After the interpreter check the session starts the pack as a background job and returns, so the first call starts at once; the worker answers calls on its main thread while a separate thread writes the tar and uploads it, and the archive's ref and build record are written only when the upload completes. The upload session needs the archive's digest and size, so the worker reports them once the tar is written, and a client thread that checks every 2 s creates the resumable session and hands its URL to the worker, which then uploads; the client records and reports the result at the start and end of a call and at session close. A session that ends first leaves no ref, and the next session syncs and may pack again. While it runs the pack says nothing more after `caching the environment, <size> GB`; when it ends it says `letify: <runtime>: cached the environment, <size> GB in <s> s (<rate> MiB/s)`, or the Drive failure line above, on standard error of whichever call or session close observes it first. Closing the session waits up to 60 s for a pack still running and then abandons it, saying `letify: <runtime>: not caching the environment, because the session closed <s> s into the pack`.
1279
1281
 
1280
1282
  Drive's own limits apply. The archive counts against the Google account's storage, which is the person's own plan. Google documents a daily upload limit of 750 GB per user for Google Workspace accounts, independent of the storage plan; an environment archive is a few gigabytes, so letify does not approach it. Standard use of the Drive API costs nothing, and Google documents that exceeding its request quota is planned to be charged to the project's billing account later in 2026; letify's requests are a handful per session against a quota of 325,000 units per user per minute.
1281
1283
 
@@ -1910,11 +1912,26 @@ It is opt-in because it needs the user's machine to accept inbound SSH and needs
1910
1912
 
1911
1913
  The key is per session. letify generates a new ed25519 key pair for each session and appends the public key to the user's `~/.ssh/authorized_keys` with `restrict,port-forwarding,command="/bin/false"` and the comment `letify-session <alias>`. The private key reaches the remote side through the rendezvous. Closing the link removes the line. Before a new session's key is added, every line carrying the `letify-session` marker for that alias is removed, so a crashed session never leaves a key behind.
1912
1914
 
1915
+ #### Reverse SSH through a jump host <!-- id: reverse-ssh-jump -->
1916
+
1917
+ > `reverse_ssh` takes an optional `jump`, for a user's machine whose SSH server the internet reaches only through another server.
1918
+
1919
+ ```toml
1920
+ [colab_pro_plus]
1921
+ reverse_ssh = { address = "168.188.127.175", port = 30008, user = "work", jump = { address = "jump.example.org", port = 20070, user = "root", key = "~/.ssh/id_jump" } }
1922
+ ```
1923
+
1924
+ With `jump`, `address` and `port` are the user's SSH server as the jump host reaches it, and the remote side connects to `<user>@<address>:<port>` through `-o ProxyCommand="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i <session key> -W %h:%p -p <jump port> <jump user>@<jump address>"` and forwards its port back with `-R` as before. It is a ProxyCommand rather than `-J`, because the options given for the main hop on the command line do not reach a `-J` hop.
1925
+
1926
+ The per-session key authorizes both hops. Besides the user's own `~/.ssh/authorized_keys`, letify appends the session's public key to the jump host's `~/.ssh/authorized_keys` with `restrict,port-forwarding,permitopen="<address>:<port>",command="/bin/false"` and the comment `letify-session <alias>`, so the key opens a connection to the user's SSH server and nothing else. letify reaches the jump host from the user's machine with `ssh -i <jump key> -p <jump port> <jump user>@<jump address>`, `BatchMode=yes`, and edits the file there with a shell command; `key` is optional and defaults to the SSH client's own keys. Closing the link removes that line, and before a new session's key is added every line carrying the `letify-session` marker for that alias is removed from the jump host too, as on the user's machine. The jump key never leaves the user's machine.
1927
+
1928
+ A jump host that letify cannot log in to, or whose `authorized_keys` it cannot edit, fails the strategy with `reverse_ssh: the jump host <user>@<address>:<port> refused letify (<reason>)`, before anything is sent to the remote side.
1929
+
1913
1930
  ### Colab <!-- id: colab-transport -->
1914
1931
 
1915
1932
  > Colab is a `Shell` whose rendezvous is `colab exec`. It has no forward SSH, and its fallback is `colab exec` with the Colab file API.
1916
1933
 
1917
- The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list.
1934
+ The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list. `colab new` runs through the CLI's own Python with a request timeout of 600 s instead of google-auth's 120 s. google-auth binds `google.auth.transport.requests._DEFAULT_TIMEOUT` into `AuthorizedSession.request`'s signature when the module loads, so setting the module value alone changes nothing: the script sets it to 600 and wraps `AuthorizedSession.request` to default `timeout` to it, then calls the CLI's entry point `colab_cli.cli:main`. That is needed because the assignment request for an A100 can take longer than 120 s to answer and the CLI then fails with `ReadTimeout` while the server goes on assigning the machine. letify's own limit on `colab new` stays 900 s.
1918
1935
 
1919
1936
  **An assignment with no session.** `colab new` can fail after Colab has already assigned a machine, for example with `ReadTimeout` after 120 s, and the machine then bills (14.2 compute units per hour for an A100) with no local session state. `colab sessions` lists it as `[?] <endpoint> | ...`, and `colab stop -s <name>` cannot reach it. `Colab.create_session` therefore lists the account's assignments before `colab new`. When `colab new` raises, for any reason, it lists them again, releases every `?` assignment whose endpoint was not in the first list, and re-raises the original error. An assignment that existed before the attempt is never released. Each release prints one line on standard error naming the endpoint. When the first or the second listing fails, one line on standard error names the endpoint check and the error, and the original error is still the one raised. An assignment is released by endpoint with `Client.unassign` of the Colab CLI, run as `uv tool run --from google-colab-cli python -c <script> <endpoint>` with the account's environment, because the CLI has no command for it. `Colab.orphans()` returns the endpoints of the `?` rows of `colab sessions` and `Colab.stop_orphan(endpoint)` releases one, so `letify stop --orphans` covers Colab as it covers Kaggle.
1920
1937
 
@@ -68,7 +68,7 @@ distributed: Final = Host.distributed
68
68
  # The enum class stays importable from letify.declare.instance for letify's own use.
69
69
  del Host
70
70
 
71
- __version__ = "2.8.1"
71
+ __version__ = "2.8.3"
72
72
 
73
73
 
74
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -2367,6 +2367,7 @@ def _op_push_blob(request):
2367
2367
 
2368
2368
  url = request.pop("url")
2369
2369
  headers = request.pop("headers", None) or {}
2370
+ cancel = request.pop("cancel", None)
2370
2371
  path = _data_file(os.path.expanduser(request["dir"]), request["digest"])
2371
2372
  size = os.path.getsize(path)
2372
2373
  piece = int(request.get("piece") or (64 << 20))
@@ -2374,6 +2375,8 @@ def _op_push_blob(request):
2374
2375
  with open(path, "rb") as handle:
2375
2376
  offset = 0
2376
2377
  while offset < size:
2378
+ if cancel is not None and cancel.is_set():
2379
+ raise RuntimeError("the upload was abandoned")
2377
2380
  block = handle.read(piece)
2378
2381
  end = offset + len(block)
2379
2382
  span = "bytes %d-%d/%d" % (offset, end - 1, size)
@@ -2395,6 +2398,98 @@ def _op_push_blob(request):
2395
2398
  return {"ok": True, "value": {"id": remote_id, "size": size}}
2396
2399
 
2397
2400
 
2401
+ #: The one environment pack running beside the session's calls, or None. Spec "Project
2402
+ #: data / environment archive": the pack is a background job on the worker.
2403
+ _PACK_JOB = {}
2404
+
2405
+
2406
+ def _pack_push_run(job, request):
2407
+ """Pack a directory, wait for the upload target, upload it, and remove the staging dir.
2408
+
2409
+ Runs on its own thread, so the worker's main thread keeps answering calls. The state
2410
+ goes packing, packed, uploading, then done or failed. The staging directory is gone
2411
+ before done or failed is reported.
2412
+ """
2413
+ import shutil
2414
+
2415
+ began = time.monotonic()
2416
+ directory = os.path.expanduser(request["dir"])
2417
+ result = None
2418
+ try:
2419
+ packed = _op_pack_blob(
2420
+ {"path": request["path"], "dir": request["dir"], "gzip": False}
2421
+ )["value"]
2422
+ job["digest"], job["size"] = packed["digest"], packed["size"]
2423
+ job["state"] = "packed"
2424
+ job["ready"].wait()
2425
+ target = job["target"]
2426
+ if target is None:
2427
+ raise RuntimeError("the pack was abandoned")
2428
+ job["state"] = "uploading"
2429
+ sent = _op_push_blob(
2430
+ {
2431
+ "dir": packed["dir"],
2432
+ "digest": packed["digest"],
2433
+ "url": target["url"],
2434
+ "headers": target.get("headers") or {},
2435
+ "piece": target.get("piece"),
2436
+ "cancel": job["cancel"],
2437
+ }
2438
+ )["value"]
2439
+ result = {"state": "done", "id": sent["id"], "size": sent["size"]}
2440
+ except BaseException as exc:
2441
+ result = {"state": "failed", "reason": str(exc) or type(exc).__name__}
2442
+ finally:
2443
+ shutil.rmtree(directory, ignore_errors=True)
2444
+ result["seconds"] = time.monotonic() - began
2445
+ job.update(result)
2446
+
2447
+
2448
+ def _op_pack_push_start(request):
2449
+ """Start packing a directory on a thread of its own and return at once."""
2450
+ import threading
2451
+
2452
+ current = _PACK_JOB.get("job")
2453
+ if current is not None and current["state"] in ("packing", "packed", "uploading"):
2454
+ return {"ok": False, "error": "a pack is already running", "traceback": ""}
2455
+ job = {
2456
+ "state": "packing",
2457
+ "target": None,
2458
+ "ready": threading.Event(),
2459
+ "cancel": threading.Event(),
2460
+ }
2461
+ _PACK_JOB["job"] = job
2462
+ threading.Thread(target=_pack_push_run, args=(job, request), daemon=True).start()
2463
+ return {"ok": True, "value": None}
2464
+
2465
+
2466
+ def _op_pack_push_target(request):
2467
+ """Hand the running pack its upload session, or abandon it when ``url`` is None."""
2468
+ job = _PACK_JOB.get("job")
2469
+ if job is None:
2470
+ return {"ok": False, "error": "no pack is running", "traceback": ""}
2471
+ if request.get("url") is None:
2472
+ job["cancel"].set()
2473
+ job["target"] = None
2474
+ else:
2475
+ job["target"] = {
2476
+ "url": request["url"],
2477
+ "headers": request.get("headers") or {},
2478
+ "piece": request.get("piece"),
2479
+ }
2480
+ job["ready"].set()
2481
+ return {"ok": True, "value": None}
2482
+
2483
+
2484
+ def _op_pack_push_state(request):
2485
+ """Report the pack: its state, and the digest, size, file id, seconds or reason it has."""
2486
+ job = _PACK_JOB.get("job")
2487
+ if job is None:
2488
+ return {"ok": True, "value": {"state": "none"}}
2489
+ keys = ("state", "digest", "size", "id", "seconds", "reason")
2490
+ return {"ok": True, "value": {key: job[key] for key in keys if key in job}}
2491
+
2492
+
2398
2493
  def _op_place_blob(request):
2399
2494
  """Move a received file blob to its destination path, and unpack it when asked.
2400
2495
 
@@ -2539,6 +2634,9 @@ _OPS = {
2539
2634
  "pack_dir": _op_pack_dir,
2540
2635
  "pack_blob": _op_pack_blob,
2541
2636
  "push_blob": _op_push_blob,
2637
+ "pack_push_start": _op_pack_push_start,
2638
+ "pack_push_target": _op_pack_push_target,
2639
+ "pack_push_state": _op_pack_push_state,
2542
2640
  "place_blob": _op_place_blob,
2543
2641
  "exec": _op_exec,
2544
2642
  "eval": _op_eval,
@@ -2553,7 +2651,10 @@ _OPS = {
2553
2651
  #: Answered by the frame reader at once, so they work while a call runs.
2554
2652
  # ``data_failed`` is light because it must not queue behind the call it is about: the call
2555
2653
  # is blocked on a blob the sender will never send, so a queued answer would deadlock.
2556
- _LIGHT = ("stat", "lease", "reduce_peers", "drive_grant", "data_failed")
2654
+ _LIGHT = (
2655
+ "stat", "lease", "reduce_peers", "drive_grant", "data_failed",
2656
+ "pack_push_state", "pack_push_target",
2657
+ )
2557
2658
 
2558
2659
  #: Answered by several replies, the last one marked. These send their own replies.
2559
2660
  _STREAMING = ("data_stream",)
@@ -131,6 +131,26 @@ UNASSIGN_SCRIPT = (
131
131
  )
132
132
 
133
133
 
134
+ #: Run in the CLI's own Python: ``colab`` with google-auth's request timeout raised to 600 s.
135
+ #: The CLI passes no timeout, so a slow A100 assignment fails at the 120 s default while the
136
+ #: server goes on assigning. google-auth binds the 120 s default into the signature of
137
+ #: ``AuthorizedSession.request`` when it is defined, so setting the module value alone changes
138
+ #: nothing and the method is wrapped as well. Spec "Colab", the Colab CLI paragraph.
139
+ NEW_SCRIPT = (
140
+ "import sys\n"
141
+ "import google.auth.transport.requests as requests\n"
142
+ "requests._DEFAULT_TIMEOUT = 600\n"
143
+ "_request = requests.AuthorizedSession.request\n"
144
+ "def _slow_request(self, *args, **kwargs):\n"
145
+ " kwargs.setdefault('timeout', requests._DEFAULT_TIMEOUT)\n"
146
+ " return _request(self, *args, **kwargs)\n"
147
+ "requests.AuthorizedSession.request = _slow_request\n"
148
+ "from colab_cli.cli import main\n"
149
+ "sys.argv = ['colab', *sys.argv[1:]]\n"
150
+ "sys.exit(main())\n"
151
+ )
152
+
153
+
134
154
  def _parse_assignments(listing: str) -> list[tuple[str, str]]:
135
155
  """The ``(name, endpoint)`` pairs in ``colab sessions`` output, ``?`` for no local state."""
136
156
  found = []
@@ -275,8 +295,21 @@ class Colab(Shell):
275
295
 
276
296
  # -- the CLI -------------------------------------------------------------
277
297
 
278
- def _cli(self, *args: str, timeout: float | None = None, stdin: str | None = None) -> str:
279
- command = [*self._colab(), *args]
298
+ def _cli(
299
+ self,
300
+ *args: str,
301
+ timeout: float | None = None,
302
+ stdin: str | None = None,
303
+ script: str | None = None,
304
+ ) -> str:
305
+ """Run the CLI; with ``script``, run that code in the CLI's Python with ``args`` as argv."""
306
+ if script is None:
307
+ command = [*self._colab(), *args]
308
+ else:
309
+ uv = tools.find_uv()
310
+ if uv is None:
311
+ raise ProviderUnavailable(self.kind, tools.missing_uv_message())
312
+ command = [*tools.python_command(tools.COLAB, uv), "-c", script, *args]
280
313
  result = subprocess.run(
281
314
  command,
282
315
  input=stdin,
@@ -286,9 +319,10 @@ class Colab(Shell):
286
319
  env=self._env(),
287
320
  )
288
321
  if result.returncode != 0:
322
+ shown = ["<script>" if part == script else part for part in command]
289
323
  raise RuntimeFailure(
290
- f"`{' '.join(command)}` exited {result.returncode}",
291
- command=" ".join(command),
324
+ f"`{' '.join(shown)}` exited {result.returncode}",
325
+ command=" ".join(shown),
292
326
  stderr=(result.stderr or "").strip(),
293
327
  )
294
328
  return result.stdout
@@ -482,7 +516,7 @@ class Colab(Shell):
482
516
  file=sys.stderr,
483
517
  )
484
518
  try:
485
- self._cli(*args, timeout=900)
519
+ self._cli(*args, timeout=900, script=NEW_SCRIPT)
486
520
  except BaseException:
487
521
  # A timed out `colab new` can still leave a server-side assignment behind.
488
522
  if before is not None:
@@ -281,13 +281,15 @@ def workspace_pack_source(root: str, archive: str) -> str:
281
281
  Spec "Volumes on a persistent runtime": runs ``tar`` from the directory's parent into a
282
282
  temporary name and renames it into place, so a concurrent session never reads a half
283
283
  written archive. Uncompressed, because gzip costs more than it saves on a local volume.
284
+ The temporary name carries random hex, not the process id, because every Modal sandbox
285
+ runs its worker under the same pid. A failed pack removes its temporary file.
284
286
  """
285
287
  return (
286
- "import os, subprocess\n"
288
+ "import os, secrets, subprocess\n"
287
289
  f"_root = os.path.expanduser({root!r}).rstrip('/')\n"
288
290
  f"_archive = os.path.expanduser({archive!r})\n"
289
291
  "os.makedirs(os.path.dirname(_archive), exist_ok=True)\n"
290
- "_partial = _archive + '.' + str(os.getpid()) + '.partial'\n"
292
+ "_partial = _archive + '.' + secrets.token_hex(8) + '.partial'\n"
291
293
  "try:\n"
292
294
  " subprocess.run(['tar', '-cf', _partial, '-C', os.path.dirname(_root),\n"
293
295
  " os.path.basename(_root)], check=True, capture_output=True)\n"
@@ -22,6 +22,7 @@ import datetime
22
22
  import json
23
23
  import posixpath
24
24
  import sys
25
+ import threading
25
26
  import time
26
27
  from collections.abc import Callable, Iterator
27
28
  from dataclasses import dataclass, field
@@ -62,6 +63,16 @@ DRIVE_DOWNLOAD_BPS = 176 * 1024**2
62
63
  #: 170 MB/s on a sandbox disk. Spec "Materializing into a runtime".
63
64
  EXTRACT_BPS = 170_000_000
64
65
 
66
+ #: How many times the estimated restore a build must have taken before the environment is
67
+ #: packed to Drive. Spec "Materializing into a runtime": packing has a cost of its own.
68
+ DRIVE_PACK_FACTOR = 2
69
+
70
+ #: Seconds a closing session waits for a pack still running before it abandons it.
71
+ PACK_CLOSE_WAIT_S = 60.0
72
+
73
+ #: Seconds between the checks that hand a finished pack its upload session.
74
+ PACK_POLL_S = 2.0
75
+
65
76
 
66
77
  def _against_the_build(size: int, rate: float, build_s: float) -> str:
67
78
  """The three numbers both cache decisions weigh, as they are reported.
@@ -87,6 +98,15 @@ def _sync_rather_than_restore(archive_bytes: int, rate: float, build_s: float) -
87
98
  )
88
99
 
89
100
 
101
+ @dataclass
102
+ class _DrivePack:
103
+ """A pack the worker is running for a Drive store, as this process tracks it."""
104
+
105
+ volume: Any
106
+ began: float = field(default_factory=time.monotonic)
107
+ target_sent: bool = False
108
+
109
+
90
110
  @dataclass
91
111
  class Runtime:
92
112
  """One live session on one provider."""
@@ -155,6 +175,11 @@ class Runtime:
155
175
  #: Set once ``shutdown`` has run, so a second discard does nothing.
156
176
  _shut_down: bool = field(default=False, repr=False)
157
177
 
178
+ #: The environment pack running on the worker, or None. Spec "Materializing into a
179
+ #: runtime": the pack to Drive runs beside the session's calls.
180
+ _pack: _DrivePack | None = field(default=None, repr=False)
181
+ _pack_lock: Any = field(default_factory=threading.RLock, repr=False)
182
+
158
183
  @property
159
184
  def key(self) -> str:
160
185
  """Pool key. Runtimes with equal keys are interchangeable."""
@@ -215,14 +240,12 @@ class Runtime:
215
240
  + (f", {size / 1_000_000_000:.1f} GB" if size else "")
216
241
  )
217
242
  volume = self._environment_volumes()[0]
218
- try:
243
+ if volume.store.backend.moves_on_runtime:
244
+ self._start_drive_pack(volume, root)
245
+ else:
219
246
  volume.cache_env_from(
220
247
  self, self.env, root, platform=self.platform, build_s=self.env_build_s
221
248
  )
222
- except Exception as exc:
223
- if not volume.store.backend.moves_on_runtime:
224
- raise
225
- self._not_using_drive(exc, after="not caching the environment")
226
249
  self.keep_blobs_on_disk()
227
250
  for volume in self.volumes:
228
251
  self.attach(volume)
@@ -263,6 +286,7 @@ class Runtime:
263
286
  if self._shut_down:
264
287
  return
265
288
  self._shut_down = True
289
+ self._close_pack()
266
290
  timestamp = datetime.datetime.now(datetime.UTC).isoformat()
267
291
  print(
268
292
  f"{timestamp} letify: discarding {self.name} provider={self.provider.alias} "
@@ -371,9 +395,13 @@ class Runtime:
371
395
  fn, args, kwargs, timeout=timeout, modules=self.env.ship_modules
372
396
  )
373
397
  args, kwargs = self._externalize(args, kwargs)
374
- return self._call_with_data(
375
- fn, args, kwargs, timeout, data_order, data_first_wave, replica
376
- )
398
+ self._poll_pack()
399
+ try:
400
+ return self._call_with_data(
401
+ fn, args, kwargs, timeout, data_order, data_first_wave, replica
402
+ )
403
+ finally:
404
+ self._poll_pack()
377
405
 
378
406
  def _call_with_data(
379
407
  self,
@@ -695,46 +723,6 @@ class Runtime:
695
723
  )
696
724
  return digest, size
697
725
 
698
- def pack_dir_to_store(self, path: str, backend: Any) -> tuple[str, int]:
699
- """Pack a directory inside the runtime and have the runtime upload the archive.
700
-
701
- Spec "Materializing into a runtime": the archive goes from the runtime's blob cache
702
- to the backend without crossing this process. This process creates the upload
703
- session, because it holds the credential, and the runtime is sent that session's
704
- URL alone. Returns ``(digest, size)``. Needs a persistent channel, and raises
705
- rather than routing the archive through this process.
706
- """
707
- if not self.persistent_channel:
708
- raise RuntimeFailure("this channel keeps no worker that could upload the archive")
709
- staging = f"{posixpath.dirname(path.rstrip('/'))}/.letify-pack"
710
- value = self.request(
711
- {"op": "pack_blob", "path": path, "dir": staging, "gzip": False}, timeout=3600
712
- )
713
- digest, size, directory = value["digest"], int(value["size"]), value["dir"]
714
- try:
715
- if not backend.has(digest):
716
- target = backend.begin_upload(digest, size)
717
- if target is None:
718
- raise RuntimeFailure(f"the {backend.name} backend offered no upload")
719
- sent = self.request(
720
- {
721
- "op": "push_blob",
722
- "dir": directory,
723
- "digest": digest,
724
- "url": target["url"],
725
- "headers": dict(target.get("headers") or {}), # type: ignore[call-overload]
726
- "piece": target.get("piece"),
727
- },
728
- timeout=3600,
729
- )
730
- backend.record_upload(digest, str(sent["id"]))
731
- finally:
732
- self.exec(
733
- f"import shutil\nshutil.rmtree({directory!r}, ignore_errors=True)\n",
734
- timeout=120,
735
- )
736
- return digest, size
737
-
738
726
  def put_path(
739
727
  self,
740
728
  local: Path,
@@ -838,7 +826,16 @@ class Runtime:
838
826
  "caching the environment on the workspace root"
839
827
  + (f", {size / 1_000_000_000:.1f} GB" if size else "")
840
828
  )
841
- self.eval(bootstrap.workspace_pack_source(root, self._workspace_archive()), timeout=3600)
829
+ source = bootstrap.workspace_pack_source(root, self._workspace_archive())
830
+ try:
831
+ self.eval(source, timeout=3600)
832
+ except Exception as error:
833
+ # The environment is built and checked, so a failed cache must not fail the call.
834
+ lines = str(error).strip().splitlines()
835
+ reason = f"{type(error).__name__}: {lines[0]}" if lines else type(error).__name__
836
+ if len(reason) > 200:
837
+ reason = reason[:197] + "..."
838
+ self._say(f"not caching the environment on the workspace root ({reason})")
842
839
 
843
840
  def _environment_volumes(self) -> tuple[Volume, ...]:
844
841
  """Explicit archive volumes, or the automatic cache where the project does not survive.
@@ -937,24 +934,167 @@ class Runtime:
937
934
  return False
938
935
 
939
936
  def _worth_packing_to_drive(self, root: str) -> bool:
940
- """Whether the build took longer than restoring the archive from Drive would.
937
+ """Whether the build took at least twice as long as restoring from Drive would.
941
938
 
942
939
  Spec "Materializing into a runtime": the archive goes from the runtime to Drive and
943
940
  never over this link, so the gate is the estimated restore, the project directory's
944
- size over the Drive download rate plus the same size over the extraction rate.
941
+ size over the Drive download rate plus the same size over the extraction rate. The
942
+ build must take ``DRIVE_PACK_FACTOR`` times that, because the pack has a cost of its
943
+ own that the comparison leaves out.
945
944
  """
946
945
  size = self._tree_size(root)
947
946
  if size is None or self.env_build_s is None:
948
947
  return True
949
948
  restore_s = size / DRIVE_DOWNLOAD_BPS + size / EXTRACT_BPS
950
- if self.env_build_s > restore_s:
949
+ if self.env_build_s >= DRIVE_PACK_FACTOR * restore_s:
951
950
  return True
952
951
  self._say(
953
952
  f"not caching the environment, because it built in {self.env_build_s:.0f} s "
954
- f"and would take an estimated {restore_s:.0f} s to restore from Drive"
953
+ f"and would take an estimated {restore_s:.0f} s to restore from Drive, and a "
954
+ f"cache pays only when the build takes at least {DRIVE_PACK_FACTOR} times as long"
955
955
  )
956
956
  return False
957
957
 
958
+ # -- the pack that runs beside the calls ---------------------------------
959
+
960
+ def _start_drive_pack(self, volume: Volume, root: str) -> None:
961
+ """Have the worker pack the environment and upload it on a thread of its own.
962
+
963
+ Spec "Materializing into a runtime": this returns at once, so the first call does
964
+ not wait for the pack. The ref and the build record are written when a poll sees
965
+ the upload finish. A failure to start leaves nothing cached and says so.
966
+ """
967
+ staging = f"{posixpath.dirname(root.rstrip('/'))}/.letify-pack"
968
+ try:
969
+ if not self.persistent_channel:
970
+ raise RuntimeFailure("this channel keeps no worker that could upload the archive")
971
+ self.request(
972
+ {"op": "pack_push_start", "path": root, "dir": staging}, timeout=120
973
+ )
974
+ except Exception as exc:
975
+ self._not_using_drive(exc, after="not caching the environment")
976
+ return
977
+ self._pack = _DrivePack(volume)
978
+ threading.Thread(target=self._feed_pack, name="letify-pack", daemon=True).start()
979
+
980
+ def _feed_pack(self) -> None:
981
+ """Hand the pack its upload session soon after the archive is written.
982
+
983
+ A call can run for hours, and the upload cannot start until this process has made
984
+ the session, so this does only that. Reporting stays with the calls and the close.
985
+ """
986
+ while True:
987
+ time.sleep(PACK_POLL_S)
988
+ job = self._pack
989
+ if job is None or job.target_sent or self._shut_down:
990
+ return
991
+ self._poll_pack(report=False)
992
+
993
+ def _poll_pack(self, *, report: bool = True) -> bool:
994
+ """Ask the worker how the pack is doing and act on the answer.
995
+
996
+ A packed archive is given its upload session. A finished upload is recorded, and
997
+ reported when ``report`` is set. False when the worker could not be asked.
998
+ """
999
+ with self._pack_lock:
1000
+ job = self._pack
1001
+ if job is None or self.channel is None:
1002
+ return True
1003
+ try:
1004
+ state = self.request({"op": "pack_push_state"}, timeout=30)
1005
+ except Exception:
1006
+ return False
1007
+ kind = state.get("state")
1008
+ backend = job.volume.store.backend
1009
+ if kind == "packed" and not job.target_sent:
1010
+ job.target_sent = True
1011
+ try:
1012
+ if backend.has(state["digest"]):
1013
+ # Another session stored this archive first, so there is nothing to send.
1014
+ self._send_target(None)
1015
+ self._pack = None
1016
+ self._record_pack(job, state["digest"], int(state["size"]))
1017
+ return True
1018
+ target = backend.begin_upload(state["digest"], int(state["size"]))
1019
+ if target is None:
1020
+ raise RuntimeFailure(f"the {backend.name} backend offered no upload")
1021
+ self._send_target(target)
1022
+ except Exception as exc:
1023
+ self._pack = None
1024
+ try:
1025
+ self._send_target(None)
1026
+ except Exception:
1027
+ pass
1028
+ self._not_using_drive(exc, after="not caching the environment")
1029
+ elif kind == "done" and report:
1030
+ self._pack = None
1031
+ backend.record_upload(state["digest"], str(state["id"]))
1032
+ self._record_pack(job, state["digest"], int(state["size"]))
1033
+ seconds = float(state["seconds"])
1034
+ size = int(state["size"])
1035
+ self._say(
1036
+ f"cached the environment, {size / 1_000_000_000:.1f} GB in {seconds:.0f} s "
1037
+ f"({size / 1024**2 / max(seconds, 0.001):.1f} MiB/s)"
1038
+ )
1039
+ elif kind == "failed" and report:
1040
+ self._pack = None
1041
+ self._not_using_drive(
1042
+ RuntimeFailure(str(state.get("reason"))), after="not caching the environment"
1043
+ )
1044
+ elif kind == "none":
1045
+ self._pack = None
1046
+ return True
1047
+
1048
+ def _send_target(self, target: dict[str, Any] | None) -> None:
1049
+ """Give the worker the upload session, or None to abandon the pack."""
1050
+ payload: dict[str, Any] = {"op": "pack_push_target", "url": None}
1051
+ if target is not None:
1052
+ payload.update(
1053
+ url=target["url"],
1054
+ headers=dict(target.get("headers") or {}), # type: ignore[call-overload]
1055
+ piece=target.get("piece"),
1056
+ )
1057
+ self.request(payload, timeout=60)
1058
+
1059
+ def _record_pack(self, job: _DrivePack, digest: str, size: int) -> None:
1060
+ """Write the ref and the build record, which only a finished upload earns."""
1061
+ job.volume.record_env(
1062
+ self.env, self.platform, digest, size, build_s=self.env_build_s
1063
+ )
1064
+
1065
+ def _close_pack(self) -> None:
1066
+ """Wait for a running pack, up to ``PACK_CLOSE_WAIT_S``, then abandon it.
1067
+
1068
+ Spec "Materializing into a runtime": an abandoned pack records nothing, so the
1069
+ next session syncs and may pack again.
1070
+ """
1071
+ job = self._pack
1072
+ if job is None:
1073
+ return
1074
+ deadline = time.monotonic() + PACK_CLOSE_WAIT_S
1075
+ try:
1076
+ while self._pack is not None:
1077
+ if not self._poll_pack():
1078
+ self._pack = None
1079
+ return
1080
+ if self._pack is None:
1081
+ return
1082
+ if time.monotonic() >= deadline:
1083
+ break
1084
+ time.sleep(0.2)
1085
+ with self._pack_lock:
1086
+ self._pack = None
1087
+ try:
1088
+ self._send_target(None)
1089
+ except Exception:
1090
+ pass
1091
+ self._say(
1092
+ "not caching the environment, because the session closed "
1093
+ f"{time.monotonic() - job.began:.0f} s into the pack"
1094
+ )
1095
+ except Exception:
1096
+ self._pack = None
1097
+
958
1098
  def _restore_from(self, volume: Volume, where: dict[str, Any]) -> bool:
959
1099
  """Restore the environment from one volume's archive. True when the check passed.
960
1100