letify 2.8.7__tar.gz → 2.9.0__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 (209) hide show
  1. {letify-2.8.7 → letify-2.9.0}/PKG-INFO +1 -1
  2. {letify-2.8.7 → letify-2.9.0}/PROJECT.md +21 -0
  3. {letify-2.8.7 → letify-2.9.0}/docs/SPEC.md +58 -2
  4. {letify-2.8.7 → letify-2.9.0}/docs/guide/05-concurrency.md +47 -0
  5. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md +47 -0
  6. {letify-2.8.7 → letify-2.9.0}/letify/__init__.py +22 -1
  7. {letify-2.8.7 → letify-2.9.0}/letify/cli.py +197 -0
  8. {letify-2.8.7 → letify-2.9.0}/letify/config/__init__.py +2 -0
  9. {letify-2.8.7 → letify-2.9.0}/letify/config/schema.py +3 -0
  10. letify-2.9.0/letify/declare/exit.py +50 -0
  11. {letify-2.8.7 → letify-2.9.0}/letify/errors.py +23 -0
  12. {letify-2.8.7 → letify-2.9.0}/letify/launcher.py +1 -0
  13. {letify-2.8.7 → letify-2.9.0}/letify/paths.py +1 -0
  14. {letify-2.8.7 → letify-2.9.0}/letify/protocol/codec.py +10 -1
  15. {letify-2.8.7 → letify-2.9.0}/letify/protocol/wire.py +27 -0
  16. {letify-2.8.7 → letify-2.9.0}/letify/protocol/worker.py +697 -13
  17. {letify-2.8.7 → letify-2.9.0}/letify/providers/base.py +20 -0
  18. {letify-2.8.7 → letify-2.9.0}/letify/providers/elice.py +5 -0
  19. {letify-2.8.7 → letify-2.9.0}/letify/providers/kaggle.py +10 -2
  20. {letify-2.8.7 → letify-2.9.0}/letify/providers/local.py +13 -0
  21. {letify-2.8.7 → letify-2.9.0}/letify/providers/modal.py +6 -0
  22. {letify-2.8.7 → letify-2.9.0}/letify/providers/shell.py +39 -15
  23. {letify-2.8.7 → letify-2.9.0}/letify/runtime/channel.py +226 -2
  24. letify-2.9.0/letify/runtime/control.py +536 -0
  25. {letify-2.8.7 → letify-2.9.0}/letify/runtime/lease.py +10 -0
  26. {letify-2.8.7 → letify-2.9.0}/letify/runtime/pool.py +28 -0
  27. {letify-2.8.7 → letify-2.9.0}/letify/runtime/session.py +93 -12
  28. letify-2.9.0/letify/runtime/takeover.py +173 -0
  29. {letify-2.8.7 → letify-2.9.0}/letify/transport/link.py +3 -2
  30. {letify-2.8.7 → letify-2.9.0}/letify/transport/sshopts.py +30 -0
  31. {letify-2.8.7 → letify-2.9.0}/letify-core/Cargo.lock +2 -2
  32. {letify-2.8.7 → letify-2.9.0}/letify-core/Cargo.toml +1 -1
  33. {letify-2.8.7 → letify-2.9.0}/letify-ext/package-lock.json +2 -2
  34. {letify-2.8.7 → letify-2.9.0}/letify-ext/package.json +1 -1
  35. {letify-2.8.7 → letify-2.9.0}/pyproject.toml +1 -1
  36. {letify-2.8.7 → letify-2.9.0}/tests/test_cli.py +4 -0
  37. {letify-2.8.7 → letify-2.9.0}/tests/test_paths.py +1 -0
  38. {letify-2.8.7 → letify-2.9.0}/tests/test_providers.py +56 -0
  39. letify-2.9.0/tests/test_run_control.py +716 -0
  40. letify-2.9.0/tests/test_run_takeover.py +621 -0
  41. {letify-2.8.7 → letify-2.9.0}/.github/workflows/ci.yml +0 -0
  42. {letify-2.8.7 → letify-2.9.0}/.github/workflows/docs.yml +0 -0
  43. {letify-2.8.7 → letify-2.9.0}/.github/workflows/publish-crates.yml +0 -0
  44. {letify-2.8.7 → letify-2.9.0}/.github/workflows/publish.yml +0 -0
  45. {letify-2.8.7 → letify-2.9.0}/.github/workflows/reserve-crates.yml +0 -0
  46. {letify-2.8.7 → letify-2.9.0}/.gitignore +0 -0
  47. {letify-2.8.7 → letify-2.9.0}/.researchtree +0 -0
  48. {letify-2.8.7 → letify-2.9.0}/CLAUDE.md +0 -0
  49. {letify-2.8.7 → letify-2.9.0}/LICENSE +0 -0
  50. {letify-2.8.7 → letify-2.9.0}/README.md +0 -0
  51. {letify-2.8.7 → letify-2.9.0}/docs/COMPONENT.md +0 -0
  52. {letify-2.8.7 → letify-2.9.0}/docs/INTENT.md +0 -0
  53. {letify-2.8.7 → letify-2.9.0}/docs/NETWORK.md +0 -0
  54. {letify-2.8.7 → letify-2.9.0}/docs/guide/01-getting-started.md +0 -0
  55. {letify-2.8.7 → letify-2.9.0}/docs/guide/02-providers.md +0 -0
  56. {letify-2.8.7 → letify-2.9.0}/docs/guide/03-execution-modes.md +0 -0
  57. {letify-2.8.7 → letify-2.9.0}/docs/guide/04-environments-and-data.md +0 -0
  58. {letify-2.8.7 → letify-2.9.0}/docs/guide/06-cost.md +0 -0
  59. {letify-2.8.7 → letify-2.9.0}/docs/guide/07-troubleshooting.md +0 -0
  60. {letify-2.8.7 → letify-2.9.0}/docs/guide/08-releasing.md +0 -0
  61. {letify-2.8.7 → letify-2.9.0}/docs/guide/README.md +0 -0
  62. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  63. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  64. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  65. {letify-2.8.7 → letify-2.9.0}/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
  66. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  67. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  68. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  69. {letify-2.8.7 → letify-2.9.0}/docs/guide/ko/README.md +0 -0
  70. {letify-2.8.7 → letify-2.9.0}/docs/locales/README_ko.md +0 -0
  71. {letify-2.8.7 → letify-2.9.0}/docs/mkdocs.yml +0 -0
  72. {letify-2.8.7 → letify-2.9.0}/examples/README.md +0 -0
  73. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/.letify.example +0 -0
  74. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  75. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  76. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/02_resume.py +0 -0
  77. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/03_watch.py +0 -0
  78. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/README.md +0 -0
  79. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/README_ko.md +0 -0
  80. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/common.py +0 -0
  81. {letify-2.8.7 → letify-2.9.0}/examples/nvfp4-lora/recipe.py +0 -0
  82. {letify-2.8.7 → letify-2.9.0}/letify/config/inventory.py +0 -0
  83. {letify-2.8.7 → letify-2.9.0}/letify/config/login.py +0 -0
  84. {letify-2.8.7 → letify-2.9.0}/letify/config/secrets.py +0 -0
  85. {letify-2.8.7 → letify-2.9.0}/letify/config/writer.py +0 -0
  86. {letify-2.8.7 → letify-2.9.0}/letify/declare/__init__.py +0 -0
  87. {letify-2.8.7 → letify-2.9.0}/letify/declare/cache.py +0 -0
  88. {letify-2.8.7 → letify-2.9.0}/letify/declare/env.py +0 -0
  89. {letify-2.8.7 → letify-2.9.0}/letify/declare/function.py +0 -0
  90. {letify-2.8.7 → letify-2.9.0}/letify/declare/instance.py +0 -0
  91. {letify-2.8.7 → letify-2.9.0}/letify/declare/reduce.py +0 -0
  92. {letify-2.8.7 → letify-2.9.0}/letify/declare/replicas.py +0 -0
  93. {letify-2.8.7 → letify-2.9.0}/letify/install.py +0 -0
  94. {letify-2.8.7 → letify-2.9.0}/letify/protocol/__init__.py +0 -0
  95. {letify-2.8.7 → letify-2.9.0}/letify/protocol/driver.py +0 -0
  96. {letify-2.8.7 → letify-2.9.0}/letify/protocol/handle.py +0 -0
  97. {letify-2.8.7 → letify-2.9.0}/letify/providers/__init__.py +0 -0
  98. {letify-2.8.7 → letify-2.9.0}/letify/providers/colab.py +0 -0
  99. {letify-2.8.7 → letify-2.9.0}/letify/providers/colab_files.py +0 -0
  100. {letify-2.8.7 → letify-2.9.0}/letify/providers/colab_keepalive.py +0 -0
  101. {letify-2.8.7 → letify-2.9.0}/letify/providers/kaggle_adapter.py +0 -0
  102. {letify-2.8.7 → letify-2.9.0}/letify/providers/modal_adapter.py +0 -0
  103. {letify-2.8.7 → letify-2.9.0}/letify/providers/naming.py +0 -0
  104. {letify-2.8.7 → letify-2.9.0}/letify/providers/tunnel.py +0 -0
  105. {letify-2.8.7 → letify-2.9.0}/letify/providers/usage.py +0 -0
  106. {letify-2.8.7 → letify-2.9.0}/letify/remoting/__init__.py +0 -0
  107. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/__init__.py +0 -0
  108. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/client.py +0 -0
  109. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/cuda.py +0 -0
  110. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/executor.py +0 -0
  111. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/frames.py +0 -0
  112. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/guard.py +0 -0
  113. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/tensor.py +0 -0
  114. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/trace.py +0 -0
  115. {letify-2.8.7 → letify-2.9.0}/letify/remoting/device/value.py +0 -0
  116. {letify-2.8.7 → letify-2.9.0}/letify/render.py +0 -0
  117. {letify-2.8.7 → letify-2.9.0}/letify/runtime/__init__.py +0 -0
  118. {letify-2.8.7 → letify-2.9.0}/letify/runtime/bootstrap.py +0 -0
  119. {letify-2.8.7 → letify-2.9.0}/letify/runtime/broker.py +0 -0
  120. {letify-2.8.7 → letify-2.9.0}/letify/runtime/telemetry.py +0 -0
  121. {letify-2.8.7 → letify-2.9.0}/letify/store/__init__.py +0 -0
  122. {letify-2.8.7 → letify-2.9.0}/letify/store/backends/__init__.py +0 -0
  123. {letify-2.8.7 → letify-2.9.0}/letify/store/backends/drive.py +0 -0
  124. {letify-2.8.7 → letify-2.9.0}/letify/store/backends/filesystem.py +0 -0
  125. {letify-2.8.7 → letify-2.9.0}/letify/store/backends/google_auth.py +0 -0
  126. {letify-2.8.7 → letify-2.9.0}/letify/store/backends/layout.py +0 -0
  127. {letify-2.8.7 → letify-2.9.0}/letify/store/backends/objects.py +0 -0
  128. {letify-2.8.7 → letify-2.9.0}/letify/store/cas.py +0 -0
  129. {letify-2.8.7 → letify-2.9.0}/letify/store/handoff.py +0 -0
  130. {letify-2.8.7 → letify-2.9.0}/letify/store/pathdata.py +0 -0
  131. {letify-2.8.7 → letify-2.9.0}/letify/store/sendorder.py +0 -0
  132. {letify-2.8.7 → letify-2.9.0}/letify/store/volume.py +0 -0
  133. {letify-2.8.7 → letify-2.9.0}/letify/stubs.py +0 -0
  134. {letify-2.8.7 → letify-2.9.0}/letify/tools.py +0 -0
  135. {letify-2.8.7 → letify-2.9.0}/letify/transport/__init__.py +0 -0
  136. {letify-2.8.7 → letify-2.9.0}/letify/transport/agent.py +0 -0
  137. {letify-2.8.7 → letify-2.9.0}/letify/transport/announce.py +0 -0
  138. {letify-2.8.7 → letify-2.9.0}/letify/transport/nat.py +0 -0
  139. {letify-2.8.7 → letify-2.9.0}/letify/transport/pipeline.py +0 -0
  140. {letify-2.8.7 → letify-2.9.0}/letify/transport/portmap.py +0 -0
  141. {letify-2.8.7 → letify-2.9.0}/letify/transport/probe.py +0 -0
  142. {letify-2.8.7 → letify-2.9.0}/letify/transport/quic.py +0 -0
  143. {letify-2.8.7 → letify-2.9.0}/letify/transport/rendezvous.py +0 -0
  144. {letify-2.8.7 → letify-2.9.0}/letify/transport/setup.py +0 -0
  145. {letify-2.8.7 → letify-2.9.0}/letify/transport/strategies.py +0 -0
  146. {letify-2.8.7 → letify-2.9.0}/letify-core/.gitignore +0 -0
  147. {letify-2.8.7 → letify-2.9.0}/letify-core/build.py +0 -0
  148. {letify-2.8.7 → letify-2.9.0}/letify-core/letify/Cargo.toml +0 -0
  149. {letify-2.8.7 → letify-2.9.0}/letify-core/letify/LICENSE +0 -0
  150. {letify-2.8.7 → letify-2.9.0}/letify-core/letify/README.md +0 -0
  151. {letify-2.8.7 → letify-2.9.0}/letify-core/letify/src/lib.rs +0 -0
  152. {letify-2.8.7 → letify-2.9.0}/letify-core/letify-quic/Cargo.toml +0 -0
  153. {letify-2.8.7 → letify-2.9.0}/letify-core/letify-quic/src/main.rs +0 -0
  154. {letify-2.8.7 → letify-2.9.0}/letify-core/rust-toolchain.toml +0 -0
  155. {letify-2.8.7 → letify-2.9.0}/letify-ext/.vscodeignore +0 -0
  156. {letify-2.8.7 → letify-2.9.0}/letify-ext/LICENSE +0 -0
  157. {letify-2.8.7 → letify-2.9.0}/letify-ext/README.md +0 -0
  158. {letify-2.8.7 → letify-2.9.0}/letify-ext/media/icon.svg +0 -0
  159. {letify-2.8.7 → letify-2.9.0}/letify-ext/src/cli.ts +0 -0
  160. {letify-2.8.7 → letify-2.9.0}/letify-ext/src/extension.ts +0 -0
  161. {letify-2.8.7 → letify-2.9.0}/letify-ext/src/history.ts +0 -0
  162. {letify-2.8.7 → letify-2.9.0}/letify-ext/src/model.ts +0 -0
  163. {letify-2.8.7 → letify-2.9.0}/letify-ext/src/poll.ts +0 -0
  164. {letify-2.8.7 → letify-2.9.0}/letify-ext/src/render.ts +0 -0
  165. {letify-2.8.7 → letify-2.9.0}/letify-ext/test/cli.test.ts +0 -0
  166. {letify-2.8.7 → letify-2.9.0}/letify-ext/test/manifest.test.ts +0 -0
  167. {letify-2.8.7 → letify-2.9.0}/letify-ext/test/model.test.ts +0 -0
  168. {letify-2.8.7 → letify-2.9.0}/letify-ext/test/regressions.test.ts +0 -0
  169. {letify-2.8.7 → letify-2.9.0}/letify-ext/tsconfig.json +0 -0
  170. {letify-2.8.7 → letify-2.9.0}/letify_providers.py +0 -0
  171. {letify-2.8.7 → letify-2.9.0}/scripts/sync-main.sh +0 -0
  172. {letify-2.8.7 → letify-2.9.0}/scripts/version.py +0 -0
  173. {letify-2.8.7 → letify-2.9.0}/tests/conftest.py +0 -0
  174. {letify-2.8.7 → letify-2.9.0}/tests/fake_kaggle_adapter.py +0 -0
  175. {letify-2.8.7 → letify-2.9.0}/tests/fake_modal_adapter.py +0 -0
  176. {letify-2.8.7 → letify-2.9.0}/tests/test_cli_output.py +0 -0
  177. {letify-2.8.7 → letify-2.9.0}/tests/test_colab_keepalive.py +0 -0
  178. {letify-2.8.7 → letify-2.9.0}/tests/test_config.py +0 -0
  179. {letify-2.8.7 → letify-2.9.0}/tests/test_core.py +0 -0
  180. {letify-2.8.7 → letify-2.9.0}/tests/test_data.py +0 -0
  181. {letify-2.8.7 → letify-2.9.0}/tests/test_data_streaming.py +0 -0
  182. {letify-2.8.7 → letify-2.9.0}/tests/test_declare.py +0 -0
  183. {letify-2.8.7 → letify-2.9.0}/tests/test_device.py +0 -0
  184. {letify-2.8.7 → letify-2.9.0}/tests/test_distributed.py +0 -0
  185. {letify-2.8.7 → letify-2.9.0}/tests/test_drive_handoff.py +0 -0
  186. {letify-2.8.7 → letify-2.9.0}/tests/test_drive_store.py +0 -0
  187. {letify-2.8.7 → letify-2.9.0}/tests/test_editor_sessions.py +0 -0
  188. {letify-2.8.7 → letify-2.9.0}/tests/test_elice.py +0 -0
  189. {letify-2.8.7 → letify-2.9.0}/tests/test_frames.py +0 -0
  190. {letify-2.8.7 → letify-2.9.0}/tests/test_install.py +0 -0
  191. {letify-2.8.7 → letify-2.9.0}/tests/test_kaggle.py +0 -0
  192. {letify-2.8.7 → letify-2.9.0}/tests/test_kaggle_cookie.py +0 -0
  193. {letify-2.8.7 → letify-2.9.0}/tests/test_kaggle_link.py +0 -0
  194. {letify-2.8.7 → letify-2.9.0}/tests/test_link_log.py +0 -0
  195. {letify-2.8.7 → letify-2.9.0}/tests/test_notebook.py +0 -0
  196. {letify-2.8.7 → letify-2.9.0}/tests/test_parallel_placement.py +0 -0
  197. {letify-2.8.7 → letify-2.9.0}/tests/test_portmap.py +0 -0
  198. {letify-2.8.7 → letify-2.9.0}/tests/test_protocol.py +0 -0
  199. {letify-2.8.7 → letify-2.9.0}/tests/test_quic.py +0 -0
  200. {letify-2.8.7 → letify-2.9.0}/tests/test_remoting.py +0 -0
  201. {letify-2.8.7 → letify-2.9.0}/tests/test_render.py +0 -0
  202. {letify-2.8.7 → letify-2.9.0}/tests/test_runtime.py +0 -0
  203. {letify-2.8.7 → letify-2.9.0}/tests/test_ship.py +0 -0
  204. {letify-2.8.7 → letify-2.9.0}/tests/test_store.py +0 -0
  205. {letify-2.8.7 → letify-2.9.0}/tests/test_strategies.py +0 -0
  206. {letify-2.8.7 → letify-2.9.0}/tests/test_stubs.py +0 -0
  207. {letify-2.8.7 → letify-2.9.0}/tests/test_transport.py +0 -0
  208. {letify-2.8.7 → letify-2.9.0}/tests/test_tunnel_setup.py +0 -0
  209. {letify-2.8.7 → letify-2.9.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.8.7
3
+ Version: 2.9.0
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
@@ -190,6 +190,22 @@ cache.fetch_checkpoint("run-1", target)
190
190
  cache.resume(runtime, "run-1", path) # put the newest one inside a session
191
191
  ```
192
192
 
193
+ ### Exit hooks
194
+
195
+ ```python
196
+ letify.on_exit(fn) # inside a body: run fn if the worker ends before the body does
197
+ ```
198
+
199
+ The hook runs on `SIGTERM`, when the lease deadline passes and when the channel closes during the call, newest first, within 120 s in total. Outside a body it raises `UnsupportedMode`. `letify runs`, `interrupt` and `exec` reach a running call from the command line; see [Controlling a running call](docs/SPEC.md#run-control).
200
+
201
+ ### Taking over a running call
202
+
203
+ ```python
204
+ value = letify.takeover(run) # become the owner of a running call, return its value
205
+ ```
206
+
207
+ `letify.takeover(run)` and `letify takeover <run>` make the calling process the owner of a call another process started: it renews the lease, receives the output and the result, writes the call's outputs back under the project the call was sent from, and ends the session. The previous owner, if it is alive, is asked to let go and its waiting call raises `OwnershipMoved`. A provider whose runtime a second process cannot reach raises `UnsupportedMode` naming it; see [Taking over a running call](docs/SPEC.md#run-takeover).
208
+
193
209
  ### Handoffs
194
210
 
195
211
  ```python
@@ -225,6 +241,7 @@ Both work in the local process and inside a shipped function, on any provider. T
225
241
  | `ProtocolError` | The remote process died before producing a result | yes |
226
242
  | `RemoteError` | The shipped function raised; carries the remote traceback | no |
227
243
  | `UnsupportedMode` | The requested mode cannot work here | no |
244
+ | `OwnershipMoved` | The call you were waiting on was taken over by another process, which now receives its result | no |
228
245
  | `InsufficientDevices` | The devices a call asks for cannot be allocated; names what holds them | no |
229
246
 
230
247
  ### Command line
@@ -237,6 +254,10 @@ letify devices # accelerators each provider offers
237
254
  letify status # live sessions and what they are costing
238
255
  letify usage [alias] # what is left on each account
239
256
  letify utilization [alias] # how busy each instance's accelerator is
257
+ letify runs # calls running from this machine, with their run ids
258
+ letify interrupt <run> # raise KeyboardInterrupt in a running call's body
259
+ letify exec <run> CODE # run source beside a running call (-f FILE, --timeout S)
260
+ letify takeover <run> # become the owner of a running call (--result PATH, --json)
240
261
  letify check <alias> # confirm a machine answers
241
262
  letify efficiency 0.5 3 150 # expected share of a direct run
242
263
  letify stubs # write typings/letify_providers.pyi for editor completion
@@ -818,7 +818,7 @@ A sandbox that reaches that lifetime takes its worker with it, so the channel se
818
818
 
819
819
  Otherwise a Modal worker that stopped without replying is followed by one `poll` of its sandbox from a second adapter process, waiting at most 30 s for the exit code, and the failure names what the poll found instead of listing causes: `the Modal sandbox exited with code <n>` for a sandbox that ended, with `, which is SIGKILL, most often the container running out of memory` added for 137, and `the Modal sandbox is still running, so the worker process died inside it` for one still running. A poll that fails or times out leaves the message as it was. The message is what tells an out-of-memory kill from a preemption, which a training run that died twice in one day could not.
820
820
 
821
- A worker reached through a subprocess (`PersistentChannel`, for example `ssh` to a Colab or Kaggle machine) that exits before it says hello, or while a call is running, has its exit code named in the message text, so a user can tell a lost connection from a kill from a crash. A signal death is reported as the shell does, 128 plus the signal number. The message reads `the worker exited with code <n>` followed by a short meaning: `255` adds `: ssh could not reach or lost the machine`, `128+N` adds `: killed by signal N`, with `137` adding `, which is SIGKILL, most often out of memory`, and any other nonzero code adds nothing. A worker that never said hello reads `the worker exited before it was ready, with code <n>` followed by the same meaning. The last 2000 characters of the process's standard error follow under `--- last remote output ---`, and the startup failure also keeps the whole text as its `stderr` attribute. A channel whose provider can name a cause itself, such as Modal, keeps that cause. When the command's program is `ssh` or `ssh.exe`, `PersistentChannel` starts it as `ssh -E <file> ...`, with a fresh temporary file per channel placed right after the program. `ControlPersist` forks a master whose standard error is /dev/null, so its errors (`Timeout, server not responding`, `Connection reset`, `closed by remote host`, `Broken pipe`) reach only that file. A death message, both before hello and during a call, appends the last 2000 characters of the file under `--- ssh log ---` when it is not empty, after the standard error of the ssh client. Other commands are not changed. The file is deleted when the channel closes.
821
+ A worker reached through a subprocess (`PersistentChannel`, for example `ssh` to a Colab or Kaggle machine) that exits before it says hello, or while a call is running, has its exit code named in the message text, so a user can tell a lost connection from a kill from a crash. A signal death is reported as the shell does, 128 plus the signal number. The message reads `the worker exited with code <n>` followed by a short meaning: `255` adds `: ssh could not reach or lost the machine`, `128+N` adds `: killed by signal N`, with `137` adding `, which is SIGKILL, most often out of memory`, and any other nonzero code adds nothing. A worker that never said hello reads `the worker exited before it was ready, with code <n>` followed by the same meaning. The last 2000 characters of the process's standard error follow under `--- last remote output ---`, and the startup failure also keeps the whole text as its `stderr` attribute. A channel whose provider can name a cause itself, such as Modal, keeps that cause. When the command's program is `ssh` or `ssh.exe`, `PersistentChannel` starts it as `ssh -E <file> ...`, with a fresh temporary file per channel placed right after the program. The ssh of a worker channel is never a client of a shared connection (see "SSH authentication"), so its own transport errors (`Connection to <host> closed by remote host.`, `Timeout, server not responding`, `Broken pipe`) reach its standard error and this file. A death message, both before hello and during a call, appends the last 2000 characters of the file under `--- ssh log ---` when it is not empty, after the standard error of the ssh client. Other commands are not changed. The file is deleted when the channel closes.
822
822
 
823
823
  ### Modal data channel <!-- id: modal-data-channel -->
824
824
 
@@ -1034,6 +1034,60 @@ Each runtime also reports `price_type`, as [Price type](#elice-price-type) descr
1034
1034
 
1035
1035
  `status()` describes this process only. A session started by a different process is not in it, since the pool lives in the process that owns it. What a machine itself is doing is a different question, answered by `letify utilization`.
1036
1036
 
1037
+ ## Controlling a running call <!-- id: run-control -->
1038
+
1039
+ > A running call can be interrupted, inspected and changed from outside, the way a local process can, without the declaration growing a handle.
1040
+
1041
+ A call on a remote runtime is a program running somewhere else. On a laptop the person who started a training loop can press Ctrl-C, attach a debugger or read a variable; letify gives the same reach to a call, so a run that turns out to save no checkpoint can still be saved. None of this adds a value to user code: a call is addressed from the command line by a run id, the way a local process is addressed by its pid, and the session lifetime in [Lifetime](#lifetime) is unchanged.
1042
+
1043
+ ### Run ids and the registry
1044
+
1045
+ Every call on a runtime gets a run id, 12 lowercase hex characters, when it is sent. The owning process writes `~/.letify/runs/<run id>.json` before the call is sent and removes it when the call returns or raises. The record holds `run`, `function` (the qualified name), `runtime`, `provider` (the account alias), `started_at` (Unix seconds), `pid` (the owning process) and `socket`, the owner's control socket. The directory is created with mode 0700 and each file with mode 0600.
1046
+
1047
+ Each owning process listens on one Unix socket, `~/.letify/runs/<pid>.sock`, created on the first call and removed at exit. A command reaches a run by reading its record and connecting to that socket; the owner forwards the request to the runtime over the call's own channel. When `~/.letify/runs/<pid>.sock` would exceed 100 bytes, the limit of a Unix socket path, the socket is created as `run-<pid>.sock` in the SSH control directory of [Declared filesystem paths](#declared-paths) instead, and `socket` in the record names it. A record whose `pid` no longer exists is stale: it is removed when read and the run is reported as not found. On Windows there is no control socket, and the commands below say so.
1048
+
1049
+ The request travels as one JSON object per line on the socket and the answer as one JSON object per line, so a tool that is not letify can drive it.
1050
+
1051
+ ### Commands
1052
+
1053
+ Every command takes `--json`, which prints one JSON object instead of text. The exit status is 0 on success, 2 for a usage error, 3 when the run is not found, and 1 for any other failure.
1054
+
1055
+ - `letify runs` lists the live runs of this machine: run id, function, runtime, provider, age and owner pid.
1056
+ - `letify interrupt <run>` raises `KeyboardInterrupt` in the call's body on the runtime, in the thread that runs it, as Ctrl-C does in a local process. The body's `except` and `finally` blocks run, so the call ends the way an interrupted local function ends: its outputs are written back as for any call that raises, and the caller sees the exception. A body that catches it keeps running.
1057
+ - `letify exec <run> CODE` or `letify exec <run> -f FILE` runs Python source inside the worker process, on a thread of its own, while the body keeps running. The source sees these names:
1058
+ - `frame`, the innermost frame of the body's thread whose code is not in the standard library, site-packages or letify, so it is the user's own loop;
1059
+ - `frames`, every such frame from the outermost to the innermost;
1060
+ - `L`, a mapping over `frame.f_locals` that records the names the source assigns, so the answer can say whether each was applied, and `G`, `frame.f_globals`.
1061
+
1062
+ When the source is a single expression the answer carries the `repr` of its value. The answer always carries what the source printed on stdout and stderr, and a traceback when it raised. `--timeout S`, 60 by default, bounds the wait for the answer, not the thread. The body is not paused, so the source sees the body's objects as they are while it runs. Mutating an object, such as `L["config"].save_every = 100`, reaches the running loop on every Python. Assigning a name, such as `L["save_every"] = 100`, reaches the running frame only on Python 3.13 and later, where `f_locals` writes through; on an earlier Python the answer lists the assigned names and says that they were not applied. A file written by the source lives on the runtime: calling `letify.stash(name, path)` in the source moves it to the Drive store, as from the body, so a checkpoint can be rescued from a runtime that may not survive.
1063
+
1064
+ A command against a run on a provider whose worker has no persistent channel, the one-shot channels, fails with `UnsupportedMode` naming the provider.
1065
+
1066
+ ### Taking over a running call <!-- id: run-takeover -->
1067
+
1068
+ > `letify takeover <run>` makes the process that runs it the owner of a running call and its session, so a training run outlives a stuck or dying owner without anything outliving its owner.
1069
+
1070
+ A call has exactly one owner at a time, and the session still ends with the call that needed it. Taking over moves that ownership to another process; it does not let a call run with no owner. The new owner renews the lease, receives the body's output and the call's result, writes the call's outputs back, and ends the session when the call ends, as the first owner would have.
1071
+
1072
+ 1. **The registry keeps an orphan.** The record also holds `project`, the project root the call was sent from, and `lease_deadline`, the Unix time at which the runtime's lease ends unless renewed, rewritten at each renewal. It also holds what a second process needs to reach the call and finish it: `door`, the path of the worker's socket below; `config`, the project `config.toml` that names the account; and `call_dir`, `blobs` and `outputs`, the runtime paths and the output locations to write back, each location stored relative to `project` when it lies inside it. No secret is among them. A record whose owner pid is gone is no longer removed when read while its `lease_deadline` is in the future: `letify runs` lists it with `owner` `gone` and the seconds left, and it can be taken over. After the deadline it is removed as stale.
1073
+ 2. **The worker keeps a second door.** While a call runs, the worker listens on a Unix socket on the runtime, `<workspace root>/.letify/run-<run id>.sock`, mode 0600. When that path is longer than 100 bytes, the limit of a Unix socket address, the worker and the relay reach the socket through `/proc/self/fd/<n>`, a descriptor of its directory, so it stays at that path; where `/proc` is missing the worker reports that the run has no second door. A channel closing while a call runs does not end the worker: the body keeps running, as it does today, until the lease deadline passes. Output the body makes while no channel is open is kept, the last 256 KiB per stream, and is sent to the next owner first. A body that returns while no channel is open keeps its result for a new owner until the lease deadline; on a provider with no lease the worker ends then, as before.
1074
+ 3. **The new owner connects.** `letify takeover <run>` reads the record, opens a channel to the same runtime through the record's provider and runtime name, and starts a relay to that socket instead of a new worker. It then sends `adopt` with the run id. The worker answers once with the call's state, which is `running` or `finished`, and from then on sends the call's output and its reply on the new channel, the reply as the last piece of the same answer. The old channel, if still open, is told `moved` on the call's stream and answers every later request on it with `moved`. A door takes one connection at a time and no request but `adopt` before it, and a second owner can adopt after the first one's connection has closed.
1075
+ 4. **The old owner lets go.** When the old owner is alive, takeover first asks it through its control socket to release the run. Its waiting call raises `OwnershipMoved` naming the run and the new owner's pid, it stops renewing that runtime's lease, and it drops the runtime from its pool without stopping it. A `keep_alive` block in the old owner no longer holds that session. An old owner that does not answer within 5 s is treated as gone.
1076
+ 5. **The call ends at the new owner.** `letify takeover` prints the body's output as it arrives. When the call returns it prints the `repr` of the value, or with `--result PATH` writes the value with pickle to `PATH`, and exits 0. When the body raises it prints the traceback and exits 1. With `--json` the last line is one object with `run`, `ok`, `value_repr` or `error`, and `result_path`. The call's outputs are written back under the record's `project`, not the current directory. The session is then ended.
1077
+ 6. **The same from Python.** `letify.takeover(run)` does the same in the calling process and returns the value or raises the body's exception, as a call does. It holds no handle: it returns once the call has ended and the session is gone. A darkpyonix kernel uses this to adopt a run started elsewhere.
1078
+
1079
+ A provider whose runtime cannot be reached by a second process refuses with `UnsupportedMode` naming it. On the `Local` provider the runtime is this machine, so the new owner connects to the socket directly, but the worker is a child of the owning process and dies with it: a `Local` run can be taken over only from an owner that is still alive.
1080
+
1081
+ ### Ctrl-C in the owning process
1082
+
1083
+ The first `KeyboardInterrupt` that reaches a thread waiting for a call's reply sends the same interrupt as `letify interrupt` and keeps waiting, so the body's handlers run and the outputs come back before the call raises. The waiting thread prints `letify: interrupting <run>; press Ctrl-C again to stop waiting`. A second `KeyboardInterrupt` during that wait stops waiting at once and propagates, as before this section existed. No signal handler is installed for this, so a notebook kernel's interrupt behaves the same way.
1084
+
1085
+ ### Exit hooks
1086
+
1087
+ `letify.on_exit(fn)`, called inside a body, registers `fn` to run when the worker is about to end while that body is still running, for a reason other than the body returning or raising. The reasons are the worker receiving `SIGTERM` (a preempted session, a provider stopping it), the lease deadline passing, and the channel closing while a call runs. Hooks run in the reverse order of registration, each at most once, on the body's process, with 120 s in total; a hook that raises is reported on stderr and the next one runs. Hooks registered by a call are dropped when that call ends. `SIGKILL`, the out of memory killer and a power cut run no code at all, so no hook runs for them; checkpoints are what covers those.
1088
+
1089
+ Outside a body, `letify.on_exit` raises `UnsupportedMode`, because a local process has `atexit` already.
1090
+
1037
1091
  ## Storage
1038
1092
 
1039
1093
  > A volume is a content addressed blob store on whichever backend a provider has. Each provider holds one volume per project, filled automatically from what a call uses, so a declaration never names one.
@@ -2102,6 +2156,7 @@ Local filesystem modifications are consolidated under the single root `~/.letify
2102
2156
  | `~/.letify/cache/tools/<alias>/` | Redirected `HOME` and `USERPROFILE` for provider CLIs (`colab`, `kaggle`). Mode 0700. Keeps CLI temporary files, packages, and `.config/` out of the credential directory. The CLI finds its token via a symbolic link pointing to `~/.letify/accounts/<alias>/token.json` (or copy fallback on systems without symlink support) | `LETIFY_HOME` |
2103
2157
  | `~/.letify/tmp/` | Temporary directory for ephemeral keys during reverse SSH negotiation and adapter stderr files. Mode 0700 | `LETIFY_HOME` |
2104
2158
  | `~/.letify/ssh/` | OpenSSH multiplexing control sockets (`ControlPath`). Mode 0700 | `XDG_RUNTIME_DIR` (moved to `$XDG_RUNTIME_DIR/letify`), `LETIFY_HOME` (with `XDG_RUNTIME_DIR`: `$XDG_RUNTIME_DIR/letify-<h>`; without it: `$LETIFY_HOME/ssh`) |
2159
+ | `~/.letify/runs/` | One record `<run id>.json` per running call and one control socket `<pid>.sock` per owning process, as [Controlling a running call](#run-control) and [Taking over a running call](#run-takeover) describe. Mode 0700 directory, mode 0600 records | `LETIFY_HOME` |
2105
2160
  | `~/.letify/runtime/` | Local provider workspace root for calls and local volumes. Holds `data/blobs`, `data/calls`, `tmp`. Falls back to reading `~/.letify-runtime` if it exists | `LETIFY_HOME` |
2106
2161
 
2107
2162
  **Documented local exceptions:**
@@ -2127,6 +2182,7 @@ Every remote write is constrained to the designated workspace root (`~/.letify-r
2127
2182
  | `<workspace root>/blobs` | Argument blobs on persistent providers | account `workspace` setting |
2128
2183
  | `<workspace root>/modules` | Shipped module sources placed on the runtime and added to sys.path | account `workspace` setting |
2129
2184
  | `<workspace root>/tmp/letify-<hex>` | Ephemeral reverse SSH key directory on remote instance | account `workspace` setting |
2185
+ | `<workspace root>/.letify/run-<run id>.sock` | The second door of a running call, mode 0600 in a mode 0700 directory, removed when the call's reply has been delivered. [Taking over a running call](#run-takeover) | account `workspace` setting |
2130
2186
 
2131
2187
  **Documented remote exceptions:**
2132
2188
 
@@ -2247,7 +2303,7 @@ So `letify login shell` sets up key authentication and treats the password as a
2247
2303
  4. The connection is confirmed with `BatchMode=yes`, which proves the key works before the alias is declared rather than at the first call.
2248
2304
  5. The machine's GPUs are detected over that confirmed connection, as described under Recording devices at login.
2249
2305
 
2250
- Every SSH command letify builds also carries `-o ControlMaster=auto`, `-o ControlPersist=60` and `-o ControlPath=<control directory>/<tag>-%C`, so the worker channel, the busy card check and every later command to the same machine share one authenticated connection, instead of paying about 0.24 s for a new one each. On Windows, where OpenSSH does not implement connection multiplexing, the three options are left out and each command opens its own connection.
2306
+ Every SSH command letify builds also carries `-o ControlMaster=auto`, `-o ControlPersist=60` and `-o ControlPath=<control directory>/<tag>-%C`, so the worker channel, the busy card check and every later command to the same machine share one authenticated connection, instead of paying about 0.24 s for a new one each. The worker channel's ssh and every transfer stream's ssh are the exception: `sshopts.unshared` removes the three options and puts `-o ControlMaster=no -o ControlPath=none` after the program, so each runs as its own connection. A detached master has no standard error and writes no `-E` file, so a client of it that loses the connection exits 255 and prints nothing; a connection of its own prints the cause. A command over a punched link (`PunchedLink`) carries none of the three options, because its local forwarding port is new for every command, so no two commands could share a connection and each would leave a master holding a punched socket for 60 s. On Windows, where OpenSSH does not implement connection multiplexing, the three options are left out and each command opens its own connection.
2251
2307
 
2252
2308
  Control sockets live in a designated directory checked before use, constrained by the Unix domain socket path limit of 108 bytes on Linux and 104 bytes on macOS, including the terminating byte:
2253
2309
 
@@ -132,6 +132,53 @@ Confirm the final candidate on the bigger card.
132
132
 
133
133
  > ⚠️ This does not apply when you need a specific capability. NVFP4 needs Blackwell tensor cores, which on Colab means `G4` and nothing else. There is no cheaper substitute.
134
134
 
135
+ ## Reaching into a running call
136
+
137
+ A call is a program running somewhere else, and you can still do to it what you would do to a local process. Every call gets a 12 character run id while it runs. `letify runs` lists the live calls of the machine:
138
+
139
+ ```bash
140
+ letify runs
141
+ letify interrupt 3f9a0c61d2b4 # raise KeyboardInterrupt in the body, as Ctrl-C does
142
+ letify exec 3f9a0c61d2b4 "L['step']" # read a local variable of the running loop
143
+ letify exec 3f9a0c61d2b4 "L['config'].save_every = 100"
144
+ letify exec 3f9a0c61d2b4 -f rescue.py --timeout 300
145
+ ```
146
+
147
+ `interrupt` runs the body's `except` and `finally` blocks, so a loop that saves in a `finally` saves. Pressing Ctrl-C in the process that made the call does the same once, then waits for the outputs. A second Ctrl-C stops waiting.
148
+
149
+ `exec` runs your source on a thread of the worker while the body keeps running. It sees `frame`, `frames`, `L` (the locals of `frame`) and `G` (its globals), where `frame` is the innermost frame of your own code. A single expression answers with its `repr`. Changing an object, as in `L["config"].save_every = 100`, reaches the loop on every Python. Assigning a name, as in `L["step"] = 0`, reaches it only on Python 3.13 and later, and the answer says so on an earlier one. A file the source writes is on the runtime, so `import letify; letify.stash("ckpt", "model.pt")` in the source moves it to the Drive store before the runtime goes away.
150
+
151
+ To save state when the runtime is taken away, register a hook inside the body:
152
+
153
+ ```python
154
+ @let.function(device=colab.G4, host=letify.remote)
155
+ def train():
156
+ letify.on_exit(lambda: letify.stash("ckpt", "model.pt"))
157
+ ...
158
+ ```
159
+
160
+ The hook runs when the worker receives `SIGTERM`, when its lease runs out, or when the channel closes during the call. It does not run for `SIGKILL` or the out of memory killer, so keep writing checkpoints as well. Hooks run newest first and share 120 seconds.
161
+
162
+ None of this works on a provider whose worker has only a one-shot channel, and the commands say which provider. There is no control socket on Windows. See the spec section "Controlling a running call".
163
+
164
+ ### Handing a call to another process
165
+
166
+ A call has one owner: the process that sent it, which renews the runtime's lease, receives the output and the result, and ends the session. When that process is stuck, or you want to close the notebook that started it, move the call to another process:
167
+
168
+ ```bash
169
+ letify takeover 3f9a0c61d2b4 # prints the body's output, then the repr of the value
170
+ letify takeover 3f9a0c61d2b4 --result out.pkl # writes the value with pickle instead
171
+ letify takeover 3f9a0c61d2b4 --json # the last line is one JSON object: run, ok, value_repr or error, result_path
172
+ ```
173
+
174
+ The old owner is asked to let go. Its waiting call raises `OwnershipMoved`, which names the run and the new owner and is never retried. It stops renewing the lease and leaves the session alone, even inside a `keep_alive` block. If it does not answer within 5 seconds it is treated as gone. The new owner prints the output from then on, writes the files the call produced under the project the call was sent from, not the current directory, and ends the session when the call ends. The exit status is 0 when the call returned, 1 when its body raised (the traceback is printed) or the run could not be taken, and 3 when no such run exists.
175
+
176
+ From Python, `value = letify.takeover("3f9a0c61d2b4")` does the same in the calling process and raises the body's exception as a call does.
177
+
178
+ If the owner died, `letify runs` still lists the call while the runtime's lease has time left, with `gone` as its owner and the seconds left. Take it over before the lease ends, or the worker ends with it. Output the body made while nobody listened is kept (the last 256 KiB) and printed first.
179
+
180
+ A provider whose runtime a second process cannot reach raises `UnsupportedMode` naming it. Local and Shell can be taken over. A Local worker is a child of its owner and has no lease, so a Local call can be taken over only while its owner is alive. Kaggle, Modal and Elice cannot be taken over, and Colab can only on its default SSH channel. See the spec section "Taking over a running call".
181
+
135
182
  ## Practical patterns
136
183
 
137
184
  **Run the configurations, collect a table.**
@@ -128,6 +128,53 @@ L4 세 개가 A100 하나보다 시간당 싸면서, A100 하나가 조합 하
128
128
 
129
129
  > ⚠️ 특정 기능이 필요할 때는 해당되지 않습니다. NVFP4는 블랙웰 텐서 코어가 필요하고, Colab에서는 `G4`뿐입니다. 더 싼 대체가 없습니다.
130
130
 
131
+ ## 실행 중인 호출에 손대기
132
+
133
+ 호출은 다른 곳에서 돌아가는 프로그램이지만, 로컬 프로세스에 하던 일을 그대로 할 수 있습니다. 호출은 실행되는 동안 12자리 run id를 갖습니다. `letify runs`는 이 머신에서 살아 있는 호출을 보여 줍니다.
134
+
135
+ ```bash
136
+ letify runs
137
+ letify interrupt 3f9a0c61d2b4 # Ctrl-C처럼 본문에 KeyboardInterrupt를 일으킴
138
+ letify exec 3f9a0c61d2b4 "L['step']" # 돌고 있는 루프의 지역 변수 읽기
139
+ letify exec 3f9a0c61d2b4 "L['config'].save_every = 100"
140
+ letify exec 3f9a0c61d2b4 -f rescue.py --timeout 300
141
+ ```
142
+
143
+ `interrupt`는 본문의 `except`와 `finally` 블록을 실행하므로, `finally`에서 저장하는 루프는 저장하고 끝납니다. 호출을 만든 프로세스에서 Ctrl-C를 한 번 누르면 같은 일을 한 뒤 출력이 돌아오기를 기다립니다. 두 번째 Ctrl-C는 기다리기를 멈춥니다.
144
+
145
+ `exec`는 본문이 계속 도는 동안 워커의 별도 스레드에서 소스를 실행합니다. 소스에서는 `frame`, `frames`, `L`(`frame`의 지역 변수), `G`(전역 변수)가 보이며, `frame`은 사용자 코드의 가장 안쪽 프레임입니다. 식 하나이면 그 값의 `repr`을 답으로 돌려줍니다. `L["config"].save_every = 100`처럼 객체를 바꾸면 어느 파이썬에서나 루프에 반영됩니다. `L["step"] = 0`처럼 이름에 대입하면 파이썬 3.13 이상에서만 반영되고, 더 낮은 버전에서는 답에 반영되지 않았다고 나옵니다. 소스가 쓴 파일은 런타임에 있으므로, 소스에서 `import letify; letify.stash("ckpt", "model.pt")`를 부르면 런타임이 사라지기 전에 Drive 저장소로 옮길 수 있습니다.
146
+
147
+ 런타임을 빼앗길 때 상태를 저장하려면 본문 안에서 훅을 등록합니다.
148
+
149
+ ```python
150
+ @let.function(device=colab.G4, host=letify.remote)
151
+ def train():
152
+ letify.on_exit(lambda: letify.stash("ckpt", "model.pt"))
153
+ ...
154
+ ```
155
+
156
+ 훅은 워커가 `SIGTERM`을 받을 때, 리스 기한이 지날 때, 호출 중에 채널이 닫힐 때 실행됩니다. `SIGKILL`이나 메모리 부족 종료에는 실행되지 않으므로 체크포인트도 계속 쓰세요. 훅은 나중에 등록한 것부터 실행되며 합쳐서 120초를 씁니다.
157
+
158
+ 워커에 일회성 채널만 있는 프로바이더에서는 이 기능을 쓸 수 없고, 명령이 그 프로바이더 이름을 알려 줍니다. Windows에는 제어 소켓이 없습니다. 스펙의 "Controlling a running call" 절을 참고하세요.
159
+
160
+ ### 호출을 다른 프로세스에 넘기기
161
+
162
+ 호출의 소유자는 하나입니다. 호출을 보낸 프로세스가 런타임의 리스를 갱신하고, 출력과 결과를 받고, 세션을 끝냅니다. 그 프로세스가 멈췄거나 호출을 시작한 노트북을 닫고 싶으면 호출을 다른 프로세스로 옮깁니다.
163
+
164
+ ```bash
165
+ letify takeover 3f9a0c61d2b4 # 본문의 출력을 보여 준 뒤 반환값의 repr를 출력
166
+ letify takeover 3f9a0c61d2b4 --result out.pkl # 반환값을 pickle로 파일에 저장
167
+ letify takeover 3f9a0c61d2b4 --json # 마지막 줄이 JSON 객체 하나: run, ok, value_repr 또는 error, result_path
168
+ ```
169
+
170
+ 이전 소유자에게 놓아 달라고 요청합니다. 기다리던 이전 소유자의 호출은 `OwnershipMoved`를 일으키고, 이 예외는 run id와 새 소유자를 알려 주며 재시도되지 않습니다. 이전 소유자는 리스 갱신을 멈추고, `keep_alive` 블록 안에서도 세션을 건드리지 않습니다. 5초 안에 답하지 않으면 사라진 것으로 봅니다. 새 소유자는 그 시점부터 출력을 보여 주고, 호출이 만든 파일을 현재 디렉터리가 아니라 호출을 보낸 프로젝트 아래에 쓰고, 호출이 끝나면 세션을 끝냅니다. 종료 코드는 호출이 반환하면 0, 본문이 예외를 일으키거나(트레이스백을 출력합니다) 호출을 넘겨받지 못하면 1, 그런 run이 없으면 3입니다.
171
+
172
+ 파이썬에서는 `value = letify.takeover("3f9a0c61d2b4")`가 호출 프로세스에서 같은 일을 하며, 호출처럼 본문의 예외를 그대로 일으킵니다.
173
+
174
+ 소유자가 죽었더라도 런타임의 리스가 남아 있는 동안은 `letify runs`가 그 호출을 소유자 `gone`과 남은 초와 함께 보여 줍니다. 리스가 끝나기 전에 넘겨받지 않으면 워커도 함께 끝납니다. 아무도 듣지 않는 동안 본문이 낸 출력은 마지막 256 KiB까지 보관했다가 먼저 출력합니다.
175
+
176
+ 다른 프로세스가 런타임에 닿을 수 없는 프로바이더에서는 그 이름과 함께 `UnsupportedMode`가 일어납니다. Local과 Shell은 넘겨받을 수 있습니다. Local의 워커는 소유자의 자식 프로세스이고 리스가 없으므로, Local 호출은 소유자가 살아 있는 동안에만 넘겨받을 수 있습니다. Kaggle, Modal, Elice는 넘겨받을 수 없고, Colab은 기본 SSH 채널에서만 가능합니다. 스펙의 "Taking over a running call" 절을 참고하세요.
177
+
131
178
  ## 실용 패턴
132
179
 
133
180
  **설정을 돌려 표로 모으기.**
@@ -33,6 +33,7 @@ from typing import Final, overload
33
33
 
34
34
  from .declare.cache import session_cache
35
35
  from .declare.env import Env
36
+ from .declare.exit import on_exit
36
37
  from .declare.function import Function
37
38
  from .declare.instance import AnyInstance, Host, Instance
38
39
  from .declare.reduce import all_reduce, mean, sum
@@ -43,6 +44,7 @@ from .errors import (
43
44
  InsufficientDevices,
44
45
  InterpreterMismatch,
45
46
  LetifyError,
47
+ OwnershipMoved,
46
48
  ProtocolError,
47
49
  ProviderUnavailable,
48
50
  ReductionFailed,
@@ -68,7 +70,7 @@ distributed: Final = Host.distributed
68
70
  # The enum class stays importable from letify.declare.instance for letify's own use.
69
71
  del Host
70
72
 
71
- __version__ = "2.8.7"
73
+ __version__ = "2.9.0"
72
74
 
73
75
 
74
76
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -93,6 +95,22 @@ def stash(name: str, path: str | os.PathLike[str]) -> str:
93
95
  return stash_handoff(name, path)
94
96
 
95
97
 
98
+ def takeover(run: str, *, launcher: Launcher | None = None) -> object:
99
+ """Become the owner of the running call ``run`` and return its value.
100
+
101
+ Spec "Taking over a running call". The previous owner, if it is alive, is asked to let go
102
+ and its waiting call raises ``OwnershipMoved``. This process then renews the runtime's
103
+ lease, prints the body's output as it arrives, writes the call's outputs back under the
104
+ project the call was sent from, ends the session, and returns the value or raises the
105
+ body's exception as a call does. It holds no handle and returns only once the call has
106
+ ended and the session is gone. ``launcher`` supplies the accounts when this process
107
+ already has one; otherwise they are read from the configuration the run was started with.
108
+ """
109
+ from .runtime.takeover import take
110
+
111
+ return take(run, launcher=launcher)
112
+
113
+
96
114
  @overload
97
115
  def unstash(name: str) -> dict[str, bytes] | None: ...
98
116
  @overload
@@ -131,6 +149,7 @@ __all__ = [
131
149
  "InterpreterMismatch",
132
150
  "Launcher",
133
151
  "LetifyError",
152
+ "OwnershipMoved",
134
153
  "ProtocolError",
135
154
  "ProviderUnavailable",
136
155
  "Providers",
@@ -151,11 +170,13 @@ __all__ = [
151
170
  "local",
152
171
  "local_steps",
153
172
  "mean",
173
+ "on_exit",
154
174
  "rank",
155
175
  "remote",
156
176
  "session_cache",
157
177
  "stash",
158
178
  "sum",
179
+ "takeover",
159
180
  "unstash",
160
181
  "world_size",
161
182
  ]
@@ -12,6 +12,8 @@ import json
12
12
  import os
13
13
  import shutil
14
14
  import sys
15
+ import time
16
+ from collections.abc import Callable
15
17
  from pathlib import Path
16
18
 
17
19
  from . import __version__, render
@@ -191,6 +193,39 @@ def build_parser() -> argparse.ArgumentParser:
191
193
  efficiency.add_argument("round_trip_ms", type=float, help="network round trip")
192
194
  efficiency.add_argument("--json", action="store_true", help="print the record unformatted")
193
195
 
196
+ runs_parser = sub.add_parser("runs", help="list the calls running from this machine")
197
+ runs_parser.add_argument("--json", action="store_true", help="print the records unformatted")
198
+ interrupt_parser = sub.add_parser(
199
+ "interrupt", help="raise KeyboardInterrupt in a running call's body"
200
+ )
201
+ interrupt_parser.add_argument("run", help="a run id, as letify runs lists it")
202
+ interrupt_parser.add_argument(
203
+ "--json", action="store_true", help="print the record unformatted"
204
+ )
205
+ exec_parser = sub.add_parser("exec", help="run Python source inside a running call's worker")
206
+ exec_parser.add_argument("run", help="a run id, as letify runs lists it")
207
+ exec_parser.add_argument("code", nargs="?", help="the source to run")
208
+ exec_parser.add_argument("-f", "--file", help="read the source from this file instead")
209
+ exec_parser.add_argument(
210
+ "--timeout",
211
+ type=float,
212
+ default=60.0,
213
+ metavar="S",
214
+ help="seconds to wait for the answer; the source keeps running after that",
215
+ )
216
+ exec_parser.add_argument("--json", action="store_true", help="print the record unformatted")
217
+
218
+ takeover_parser = sub.add_parser(
219
+ "takeover", help="become the owner of a running call and see it through"
220
+ )
221
+ takeover_parser.add_argument("run", help="a run id, as letify runs lists it")
222
+ takeover_parser.add_argument(
223
+ "--result", metavar="PATH", help="write the call's value to PATH with pickle"
224
+ )
225
+ takeover_parser.add_argument(
226
+ "--json", action="store_true", help="end with one JSON object about the call"
227
+ )
228
+
194
229
  setup_parser = sub.add_parser(
195
230
  "setup", help="install tailcat or eci from its publisher ahead of time"
196
231
  )
@@ -593,6 +628,164 @@ def _confirmed(question: str) -> bool:
593
628
  return answer in ("y", "yes")
594
629
 
595
630
 
631
+ def _run_control(args: argparse.Namespace) -> int:
632
+ """``letify runs``, ``interrupt`` and ``exec``: spec "Controlling a running call".
633
+
634
+ Exit status 0 on success, 2 for a usage error, 3 when the run is not found and 1 for
635
+ any other failure.
636
+ """
637
+ from .errors import RunNotFound, UnsupportedMode
638
+ from .runtime import control
639
+
640
+ def refuse(message: str, status: int, kind: str = "Error") -> int:
641
+ if args.json:
642
+ _json({"ok": False, "type": kind, "error": message})
643
+ else:
644
+ _fail(message)
645
+ return status
646
+
647
+ if args.command == "runs":
648
+ if not control.available():
649
+ return refuse("run control is not available on Windows", 1, "UnsupportedMode")
650
+ rows = control.list_runs(orphans=True)
651
+ if args.json:
652
+ return _json(rows)
653
+ if not rows:
654
+ print("no calls are running")
655
+ return 0
656
+ now = time.time()
657
+ table = [("RUN", "FUNCTION", "RUNTIME", "PROVIDER", "AGE", "OWNER")] + [
658
+ (
659
+ str(row["run"]),
660
+ str(row.get("function", "")),
661
+ str(row.get("runtime", "")),
662
+ str(row.get("provider", "")),
663
+ _age(now - float(row.get("started_at", now))),
664
+ f"gone, {_age(row['seconds_left'])} left"
665
+ if row.get("owner") == "gone"
666
+ else str(row.get("pid", "")),
667
+ )
668
+ for row in rows
669
+ ]
670
+ widths = [max(len(line[column]) for line in table) for column in range(6)]
671
+ for line in table:
672
+ cells = (cell.ljust(width) for cell, width in zip(line, widths, strict=True))
673
+ print(" ".join(cells).rstrip())
674
+ return 0
675
+
676
+ if args.command == "takeover":
677
+ return _takeover(args, refuse)
678
+
679
+ command: dict[str, object] = {"cmd": args.command}
680
+ wait = 90.0
681
+ if args.command == "exec":
682
+ if (args.code is None) == (args.file is None):
683
+ return refuse("give the source as CODE or with -f FILE, not both and not neither", 2)
684
+ if args.timeout <= 0:
685
+ return refuse("--timeout must be positive", 2)
686
+ try:
687
+ source = args.code if args.code is not None else Path(args.file).read_text("utf-8")
688
+ except (OSError, UnicodeDecodeError) as exc:
689
+ return refuse(f"cannot read {args.file}: {exc}", 1)
690
+ command.update({"source": source, "timeout": args.timeout})
691
+ wait = args.timeout + 30
692
+ try:
693
+ answer = control.ask(args.run, command, wait=wait)
694
+ except RunNotFound as exc:
695
+ return refuse(str(exc), 3, "RunNotFound")
696
+ except UnsupportedMode as exc:
697
+ return refuse(str(exc), 1, "UnsupportedMode")
698
+ failed = not answer.get("ok")
699
+ if args.command == "exec" and answer.get("error"):
700
+ failed = True
701
+ if args.json:
702
+ _json(answer)
703
+ return 1 if failed else 0
704
+ if not answer.get("ok"):
705
+ kind = answer.get("type") or "Error"
706
+ reason = answer.get("error") or answer.get("reason") or "failed"
707
+ _fail(f"{kind}: {reason}" if kind != "Error" else str(reason))
708
+ return 1
709
+ if args.command == "interrupt":
710
+ if not answer.get("interrupted"):
711
+ _fail(f"{args.run} was not interrupted: {answer.get('reason', 'unknown reason')}")
712
+ return 1
713
+ _say("ok", f"{args.run} interrupted")
714
+ return 0
715
+ sys.stdout.write(str(answer.get("stdout") or ""))
716
+ sys.stderr.write(str(answer.get("stderr") or ""))
717
+ if answer.get("error"):
718
+ sys.stderr.write(str(answer["error"]))
719
+ return 1
720
+ if answer.get("has_value"):
721
+ print(answer["value"])
722
+ assigned = answer.get("assigned") or []
723
+ if assigned and not answer.get("applied"):
724
+ _fail(
725
+ "this Python does not write assignments to f_locals back into the running "
726
+ f"frame, so {', '.join(map(str, assigned))} was not applied"
727
+ )
728
+ return 0
729
+
730
+
731
+ def _takeover(args: argparse.Namespace, refuse: Callable[..., int]) -> int:
732
+ """``letify takeover``: spec "Taking over a running call", item 5.
733
+
734
+ Exit status 0 when the call returned, 1 when its body raised or it could not be taken, 2
735
+ for a usage error and 3 when no such run is waiting for an owner.
736
+ """
737
+ import pickle
738
+
739
+ from .errors import RemoteError, RunNotFound, UnsupportedMode
740
+ from .runtime import control
741
+ from .runtime.takeover import take
742
+
743
+ if not control.available():
744
+ return refuse("run control is not available on Windows", 1, "UnsupportedMode")
745
+ try:
746
+ value = take(args.run)
747
+ except RunNotFound as exc:
748
+ return refuse(str(exc), 3, "RunNotFound")
749
+ except UnsupportedMode as exc:
750
+ return refuse(str(exc), 1, "UnsupportedMode")
751
+ except RemoteError as exc:
752
+ sys.stderr.write(f"{exc}\n")
753
+ if args.json:
754
+ _json_line({"run": args.run, "ok": False, "error": str(exc)})
755
+ return 1
756
+ result = None
757
+ if args.result:
758
+ result = str(args.result)
759
+ try:
760
+ Path(result).write_bytes(pickle.dumps(value))
761
+ except (OSError, pickle.PicklingError, TypeError) as exc:
762
+ return refuse(f"the value of {args.run} cannot be written to {result}: {exc}", 1)
763
+ if args.json:
764
+ _json_line(
765
+ {"run": args.run, "ok": True, "value_repr": repr(value), "result_path": result}
766
+ )
767
+ elif result is None:
768
+ print(repr(value))
769
+ else:
770
+ _say("ok", f"{args.run} returned; its value is in {result}")
771
+ return 0
772
+
773
+
774
+ def _json_line(value: object) -> None:
775
+ """One JSON object on one line, which is how a program reads the end of a stream."""
776
+ print(json.dumps(value))
777
+ sys.stdout.flush()
778
+
779
+
780
+ def _age(seconds: float) -> str:
781
+ seconds = max(0, int(seconds))
782
+ if seconds < 60:
783
+ return f"{seconds}s"
784
+ if seconds < 3600:
785
+ return f"{seconds // 60}m{seconds % 60:02d}s"
786
+ return f"{seconds // 3600}h{seconds % 3600 // 60:02d}m"
787
+
788
+
596
789
  def _json(value: object) -> int:
597
790
  print(json.dumps(value, indent=2))
598
791
  return 0
@@ -620,6 +813,10 @@ def _dispatch(args: argparse.Namespace) -> int:
620
813
  if args.command == "setup":
621
814
  return _setup_tool(args)
622
815
 
816
+ if args.command in ("runs", "interrupt", "exec", "takeover"):
817
+ # Talks to the process that owns the run, so no Launcher is built.
818
+ return _run_control(args)
819
+
623
820
  if args.command == "client":
624
821
  # Runs on the remote machine, which has no accounts, so no Launcher is built.
625
822
  return _client_shell_connect(args)
@@ -56,6 +56,8 @@ def load(path: str | Path | None = None, *, home: bool = True) -> Config:
56
56
 
57
57
  home_entries = _read_entries(home_path, config, require_kind=True) if home else {}
58
58
  project_entries = _read_entries(project_path, config, require_kind=False)
59
+ if project_path.is_file():
60
+ config.project_file = project_path.resolve()
59
61
 
60
62
  counter = 0
61
63
 
@@ -42,6 +42,9 @@ class Config:
42
42
  providers: dict[str, ProviderConfig] = field(default_factory=dict)
43
43
  defaults: dict[str, Any] = field(default_factory=dict)
44
44
  sources: list[Path] = field(default_factory=list)
45
+ #: The project's own ``config.toml`` when it exists, which a run's record names so another
46
+ #: process can find the same accounts. Spec "Taking over a running call".
47
+ project_file: Path | None = None
45
48
 
46
49
  @property
47
50
  def order(self) -> list[str]:
@@ -0,0 +1,50 @@
1
+ """``letify.on_exit``: a function to run when a body's worker is about to end.
2
+
3
+ Owns the public registration call and the errors for where it cannot work. It does not own
4
+ when hooks run, which is the worker's (``letify.protocol.worker``), nor how they are ordered
5
+ and bounded, which spec "Controlling a running call", Exit hooks, states.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from collections.abc import Callable
13
+
14
+ from ..errors import UnsupportedMode
15
+
16
+
17
+ def on_exit(fn: Callable[[], object]) -> None:
18
+ """Run ``fn`` if the worker ends while this body is still running.
19
+
20
+ Call it inside a body. ``fn`` runs, with no arguments, when the worker receives
21
+ ``SIGTERM``, when its lease deadline passes, or when the channel closes during the call.
22
+ Hooks run newest first, each once, within 120 s in total, and are dropped when the call
23
+ ends. ``SIGKILL`` and the out of memory killer run no hook. Outside a body this raises
24
+ ``UnsupportedMode``, because a local process has ``atexit``.
25
+ """
26
+ if not callable(fn):
27
+ raise TypeError(f"letify.on_exit needs a callable, got {type(fn).__name__}")
28
+ main = sys.modules.get("__main__")
29
+ register = getattr(main, "_exit_register", None)
30
+ if register is None:
31
+ worker_pid = os.environ.get("LETIFY_WORKER_PID")
32
+ if worker_pid is not None and worker_pid != str(os.getpid()):
33
+ raise UnsupportedMode(
34
+ "letify.on_exit must be called in the process letify runs the body in, not in "
35
+ f"a child process it started (a child of worker process {worker_pid})."
36
+ )
37
+ if os.environ.get("LETIFY_ONE_SHOT") is not None:
38
+ raise UnsupportedMode(
39
+ "letify.on_exit needs a persistent channel, and this body runs on a one-shot "
40
+ "channel, which ends its process when the call does."
41
+ )
42
+ raise UnsupportedMode(
43
+ "letify.on_exit registers a hook inside a body running on a runtime. "
44
+ "In a local process use atexit."
45
+ )
46
+ if not register(fn):
47
+ raise UnsupportedMode(
48
+ "letify.on_exit was called while no body is running, so there is no call to "
49
+ "attach the hook to. In a local process use atexit."
50
+ )