letify 2.8.0__tar.gz → 2.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. {letify-2.8.0 → letify-2.8.1}/PKG-INFO +1 -1
  2. {letify-2.8.0 → letify-2.8.1}/docs/SPEC.md +4 -0
  3. {letify-2.8.0 → letify-2.8.1}/letify/__init__.py +1 -1
  4. {letify-2.8.0 → letify-2.8.1}/letify/protocol/worker.py +5 -0
  5. {letify-2.8.0 → letify-2.8.1}/letify/providers/colab.py +100 -1
  6. {letify-2.8.0 → letify-2.8.1}/letify/store/handoff.py +14 -3
  7. {letify-2.8.0 → letify-2.8.1}/letify/tools.py +10 -1
  8. {letify-2.8.0 → letify-2.8.1}/letify-core/Cargo.lock +3 -3
  9. {letify-2.8.0 → letify-2.8.1}/letify-core/Cargo.toml +1 -1
  10. {letify-2.8.0 → letify-2.8.1}/letify-ext/package-lock.json +2 -2
  11. {letify-2.8.0 → letify-2.8.1}/letify-ext/package.json +1 -1
  12. {letify-2.8.0 → letify-2.8.1}/pyproject.toml +1 -1
  13. {letify-2.8.0 → letify-2.8.1}/tests/conftest.py +38 -0
  14. {letify-2.8.0 → letify-2.8.1}/tests/test_drive_handoff.py +74 -0
  15. {letify-2.8.0 → letify-2.8.1}/tests/test_providers.py +71 -1
  16. {letify-2.8.0 → letify-2.8.1}/.github/workflows/ci.yml +0 -0
  17. {letify-2.8.0 → letify-2.8.1}/.github/workflows/docs.yml +0 -0
  18. {letify-2.8.0 → letify-2.8.1}/.github/workflows/publish.yml +0 -0
  19. {letify-2.8.0 → letify-2.8.1}/.gitignore +0 -0
  20. {letify-2.8.0 → letify-2.8.1}/.researchtree +0 -0
  21. {letify-2.8.0 → letify-2.8.1}/CLAUDE.md +0 -0
  22. {letify-2.8.0 → letify-2.8.1}/LICENSE +0 -0
  23. {letify-2.8.0 → letify-2.8.1}/PROJECT.md +0 -0
  24. {letify-2.8.0 → letify-2.8.1}/README.md +0 -0
  25. {letify-2.8.0 → letify-2.8.1}/docs/COMPONENT.md +0 -0
  26. {letify-2.8.0 → letify-2.8.1}/docs/INTENT.md +0 -0
  27. {letify-2.8.0 → letify-2.8.1}/docs/NETWORK.md +0 -0
  28. {letify-2.8.0 → letify-2.8.1}/docs/guide/01-getting-started.md +0 -0
  29. {letify-2.8.0 → letify-2.8.1}/docs/guide/02-providers.md +0 -0
  30. {letify-2.8.0 → letify-2.8.1}/docs/guide/03-execution-modes.md +0 -0
  31. {letify-2.8.0 → letify-2.8.1}/docs/guide/04-environments-and-data.md +0 -0
  32. {letify-2.8.0 → letify-2.8.1}/docs/guide/05-concurrency.md +0 -0
  33. {letify-2.8.0 → letify-2.8.1}/docs/guide/06-cost.md +0 -0
  34. {letify-2.8.0 → letify-2.8.1}/docs/guide/07-troubleshooting.md +0 -0
  35. {letify-2.8.0 → letify-2.8.1}/docs/guide/08-releasing.md +0 -0
  36. {letify-2.8.0 → letify-2.8.1}/docs/guide/README.md +0 -0
  37. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  38. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  39. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  40. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  41. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  42. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  43. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  44. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  45. {letify-2.8.0 → letify-2.8.1}/docs/guide/ko/README.md +0 -0
  46. {letify-2.8.0 → letify-2.8.1}/docs/locales/README_ko.md +0 -0
  47. {letify-2.8.0 → letify-2.8.1}/docs/mkdocs.yml +0 -0
  48. {letify-2.8.0 → letify-2.8.1}/examples/README.md +0 -0
  49. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/.letify.example +0 -0
  50. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/00_smoke.py +0 -0
  51. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/01_sweep.py +0 -0
  52. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/02_resume.py +0 -0
  53. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/03_watch.py +0 -0
  54. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/README.md +0 -0
  55. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/README_ko.md +0 -0
  56. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/common.py +0 -0
  57. {letify-2.8.0 → letify-2.8.1}/examples/nvfp4-lora/recipe.py +0 -0
  58. {letify-2.8.0 → letify-2.8.1}/letify/cli.py +0 -0
  59. {letify-2.8.0 → letify-2.8.1}/letify/config/__init__.py +0 -0
  60. {letify-2.8.0 → letify-2.8.1}/letify/config/inventory.py +0 -0
  61. {letify-2.8.0 → letify-2.8.1}/letify/config/login.py +0 -0
  62. {letify-2.8.0 → letify-2.8.1}/letify/config/schema.py +0 -0
  63. {letify-2.8.0 → letify-2.8.1}/letify/config/secrets.py +0 -0
  64. {letify-2.8.0 → letify-2.8.1}/letify/config/writer.py +0 -0
  65. {letify-2.8.0 → letify-2.8.1}/letify/declare/__init__.py +0 -0
  66. {letify-2.8.0 → letify-2.8.1}/letify/declare/cache.py +0 -0
  67. {letify-2.8.0 → letify-2.8.1}/letify/declare/env.py +0 -0
  68. {letify-2.8.0 → letify-2.8.1}/letify/declare/function.py +0 -0
  69. {letify-2.8.0 → letify-2.8.1}/letify/declare/instance.py +0 -0
  70. {letify-2.8.0 → letify-2.8.1}/letify/declare/reduce.py +0 -0
  71. {letify-2.8.0 → letify-2.8.1}/letify/declare/replicas.py +0 -0
  72. {letify-2.8.0 → letify-2.8.1}/letify/errors.py +0 -0
  73. {letify-2.8.0 → letify-2.8.1}/letify/install.py +0 -0
  74. {letify-2.8.0 → letify-2.8.1}/letify/launcher.py +0 -0
  75. {letify-2.8.0 → letify-2.8.1}/letify/paths.py +0 -0
  76. {letify-2.8.0 → letify-2.8.1}/letify/protocol/__init__.py +0 -0
  77. {letify-2.8.0 → letify-2.8.1}/letify/protocol/codec.py +0 -0
  78. {letify-2.8.0 → letify-2.8.1}/letify/protocol/driver.py +0 -0
  79. {letify-2.8.0 → letify-2.8.1}/letify/protocol/handle.py +0 -0
  80. {letify-2.8.0 → letify-2.8.1}/letify/protocol/wire.py +0 -0
  81. {letify-2.8.0 → letify-2.8.1}/letify/providers/__init__.py +0 -0
  82. {letify-2.8.0 → letify-2.8.1}/letify/providers/base.py +0 -0
  83. {letify-2.8.0 → letify-2.8.1}/letify/providers/colab_files.py +0 -0
  84. {letify-2.8.0 → letify-2.8.1}/letify/providers/colab_keepalive.py +0 -0
  85. {letify-2.8.0 → letify-2.8.1}/letify/providers/elice.py +0 -0
  86. {letify-2.8.0 → letify-2.8.1}/letify/providers/kaggle.py +0 -0
  87. {letify-2.8.0 → letify-2.8.1}/letify/providers/kaggle_adapter.py +0 -0
  88. {letify-2.8.0 → letify-2.8.1}/letify/providers/local.py +0 -0
  89. {letify-2.8.0 → letify-2.8.1}/letify/providers/modal.py +0 -0
  90. {letify-2.8.0 → letify-2.8.1}/letify/providers/modal_adapter.py +0 -0
  91. {letify-2.8.0 → letify-2.8.1}/letify/providers/naming.py +0 -0
  92. {letify-2.8.0 → letify-2.8.1}/letify/providers/shell.py +0 -0
  93. {letify-2.8.0 → letify-2.8.1}/letify/providers/tunnel.py +0 -0
  94. {letify-2.8.0 → letify-2.8.1}/letify/providers/usage.py +0 -0
  95. {letify-2.8.0 → letify-2.8.1}/letify/remoting/__init__.py +0 -0
  96. {letify-2.8.0 → letify-2.8.1}/letify/remoting/capability.py +0 -0
  97. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/__init__.py +0 -0
  98. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/client.py +0 -0
  99. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/cuda.py +0 -0
  100. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/executor.py +0 -0
  101. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/frames.py +0 -0
  102. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/guard.py +0 -0
  103. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/tensor.py +0 -0
  104. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/trace.py +0 -0
  105. {letify-2.8.0 → letify-2.8.1}/letify/remoting/device/value.py +0 -0
  106. {letify-2.8.0 → letify-2.8.1}/letify/remoting/loader.py +0 -0
  107. {letify-2.8.0 → letify-2.8.1}/letify/remoting/probe.py +0 -0
  108. {letify-2.8.0 → letify-2.8.1}/letify/render.py +0 -0
  109. {letify-2.8.0 → letify-2.8.1}/letify/runtime/__init__.py +0 -0
  110. {letify-2.8.0 → letify-2.8.1}/letify/runtime/bootstrap.py +0 -0
  111. {letify-2.8.0 → letify-2.8.1}/letify/runtime/broker.py +0 -0
  112. {letify-2.8.0 → letify-2.8.1}/letify/runtime/channel.py +0 -0
  113. {letify-2.8.0 → letify-2.8.1}/letify/runtime/lease.py +0 -0
  114. {letify-2.8.0 → letify-2.8.1}/letify/runtime/pool.py +0 -0
  115. {letify-2.8.0 → letify-2.8.1}/letify/runtime/session.py +0 -0
  116. {letify-2.8.0 → letify-2.8.1}/letify/runtime/telemetry.py +0 -0
  117. {letify-2.8.0 → letify-2.8.1}/letify/store/__init__.py +0 -0
  118. {letify-2.8.0 → letify-2.8.1}/letify/store/backends/__init__.py +0 -0
  119. {letify-2.8.0 → letify-2.8.1}/letify/store/backends/drive.py +0 -0
  120. {letify-2.8.0 → letify-2.8.1}/letify/store/backends/filesystem.py +0 -0
  121. {letify-2.8.0 → letify-2.8.1}/letify/store/backends/google_auth.py +0 -0
  122. {letify-2.8.0 → letify-2.8.1}/letify/store/backends/layout.py +0 -0
  123. {letify-2.8.0 → letify-2.8.1}/letify/store/backends/objects.py +0 -0
  124. {letify-2.8.0 → letify-2.8.1}/letify/store/cas.py +0 -0
  125. {letify-2.8.0 → letify-2.8.1}/letify/store/pathdata.py +0 -0
  126. {letify-2.8.0 → letify-2.8.1}/letify/store/sendorder.py +0 -0
  127. {letify-2.8.0 → letify-2.8.1}/letify/store/volume.py +0 -0
  128. {letify-2.8.0 → letify-2.8.1}/letify/stubs.py +0 -0
  129. {letify-2.8.0 → letify-2.8.1}/letify/transport/__init__.py +0 -0
  130. {letify-2.8.0 → letify-2.8.1}/letify/transport/agent.py +0 -0
  131. {letify-2.8.0 → letify-2.8.1}/letify/transport/announce.py +0 -0
  132. {letify-2.8.0 → letify-2.8.1}/letify/transport/link.py +0 -0
  133. {letify-2.8.0 → letify-2.8.1}/letify/transport/nat.py +0 -0
  134. {letify-2.8.0 → letify-2.8.1}/letify/transport/pipeline.py +0 -0
  135. {letify-2.8.0 → letify-2.8.1}/letify/transport/portmap.py +0 -0
  136. {letify-2.8.0 → letify-2.8.1}/letify/transport/probe.py +0 -0
  137. {letify-2.8.0 → letify-2.8.1}/letify/transport/quic.py +0 -0
  138. {letify-2.8.0 → letify-2.8.1}/letify/transport/rendezvous.py +0 -0
  139. {letify-2.8.0 → letify-2.8.1}/letify/transport/setup.py +0 -0
  140. {letify-2.8.0 → letify-2.8.1}/letify/transport/sshopts.py +0 -0
  141. {letify-2.8.0 → letify-2.8.1}/letify/transport/strategies.py +0 -0
  142. {letify-2.8.0 → letify-2.8.1}/letify-core/.gitignore +0 -0
  143. {letify-2.8.0 → letify-2.8.1}/letify-core/build.py +0 -0
  144. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-agent/Cargo.toml +0 -0
  145. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-agent/src/driver.rs +0 -0
  146. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-agent/src/main.rs +0 -0
  147. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-driver/Cargo.toml +0 -0
  148. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-driver/src/client.rs +0 -0
  149. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-driver/src/exports.rs +0 -0
  150. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-driver/src/image.rs +0 -0
  151. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-driver/src/lib.rs +0 -0
  152. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-driver/src/status.rs +0 -0
  153. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-driver/src/table.rs +0 -0
  154. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-quic/Cargo.toml +0 -0
  155. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-quic/src/main.rs +0 -0
  156. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-wire/Cargo.toml +0 -0
  157. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-wire/src/lib.rs +0 -0
  158. {letify-2.8.0 → letify-2.8.1}/letify-core/letify-wire/tests/throughput.rs +0 -0
  159. {letify-2.8.0 → letify-2.8.1}/letify-core/rust-toolchain.toml +0 -0
  160. {letify-2.8.0 → letify-2.8.1}/letify-ext/.vscodeignore +0 -0
  161. {letify-2.8.0 → letify-2.8.1}/letify-ext/LICENSE +0 -0
  162. {letify-2.8.0 → letify-2.8.1}/letify-ext/README.md +0 -0
  163. {letify-2.8.0 → letify-2.8.1}/letify-ext/media/icon.svg +0 -0
  164. {letify-2.8.0 → letify-2.8.1}/letify-ext/src/cli.ts +0 -0
  165. {letify-2.8.0 → letify-2.8.1}/letify-ext/src/extension.ts +0 -0
  166. {letify-2.8.0 → letify-2.8.1}/letify-ext/src/history.ts +0 -0
  167. {letify-2.8.0 → letify-2.8.1}/letify-ext/src/model.ts +0 -0
  168. {letify-2.8.0 → letify-2.8.1}/letify-ext/src/poll.ts +0 -0
  169. {letify-2.8.0 → letify-2.8.1}/letify-ext/src/render.ts +0 -0
  170. {letify-2.8.0 → letify-2.8.1}/letify-ext/test/cli.test.ts +0 -0
  171. {letify-2.8.0 → letify-2.8.1}/letify-ext/test/manifest.test.ts +0 -0
  172. {letify-2.8.0 → letify-2.8.1}/letify-ext/test/model.test.ts +0 -0
  173. {letify-2.8.0 → letify-2.8.1}/letify-ext/test/regressions.test.ts +0 -0
  174. {letify-2.8.0 → letify-2.8.1}/letify-ext/tsconfig.json +0 -0
  175. {letify-2.8.0 → letify-2.8.1}/letify_providers.py +0 -0
  176. {letify-2.8.0 → letify-2.8.1}/scripts/sync-main.sh +0 -0
  177. {letify-2.8.0 → letify-2.8.1}/scripts/version.py +0 -0
  178. {letify-2.8.0 → letify-2.8.1}/tests/fake_kaggle_adapter.py +0 -0
  179. {letify-2.8.0 → letify-2.8.1}/tests/fake_modal_adapter.py +0 -0
  180. {letify-2.8.0 → letify-2.8.1}/tests/test_cli.py +0 -0
  181. {letify-2.8.0 → letify-2.8.1}/tests/test_cli_output.py +0 -0
  182. {letify-2.8.0 → letify-2.8.1}/tests/test_colab_keepalive.py +0 -0
  183. {letify-2.8.0 → letify-2.8.1}/tests/test_config.py +0 -0
  184. {letify-2.8.0 → letify-2.8.1}/tests/test_core.py +0 -0
  185. {letify-2.8.0 → letify-2.8.1}/tests/test_data.py +0 -0
  186. {letify-2.8.0 → letify-2.8.1}/tests/test_data_streaming.py +0 -0
  187. {letify-2.8.0 → letify-2.8.1}/tests/test_declare.py +0 -0
  188. {letify-2.8.0 → letify-2.8.1}/tests/test_device.py +0 -0
  189. {letify-2.8.0 → letify-2.8.1}/tests/test_distributed.py +0 -0
  190. {letify-2.8.0 → letify-2.8.1}/tests/test_drive_store.py +0 -0
  191. {letify-2.8.0 → letify-2.8.1}/tests/test_editor_sessions.py +0 -0
  192. {letify-2.8.0 → letify-2.8.1}/tests/test_elice.py +0 -0
  193. {letify-2.8.0 → letify-2.8.1}/tests/test_frames.py +0 -0
  194. {letify-2.8.0 → letify-2.8.1}/tests/test_install.py +0 -0
  195. {letify-2.8.0 → letify-2.8.1}/tests/test_kaggle.py +0 -0
  196. {letify-2.8.0 → letify-2.8.1}/tests/test_kaggle_cookie.py +0 -0
  197. {letify-2.8.0 → letify-2.8.1}/tests/test_kaggle_link.py +0 -0
  198. {letify-2.8.0 → letify-2.8.1}/tests/test_link_log.py +0 -0
  199. {letify-2.8.0 → letify-2.8.1}/tests/test_notebook.py +0 -0
  200. {letify-2.8.0 → letify-2.8.1}/tests/test_parallel_placement.py +0 -0
  201. {letify-2.8.0 → letify-2.8.1}/tests/test_paths.py +0 -0
  202. {letify-2.8.0 → letify-2.8.1}/tests/test_portmap.py +0 -0
  203. {letify-2.8.0 → letify-2.8.1}/tests/test_protocol.py +0 -0
  204. {letify-2.8.0 → letify-2.8.1}/tests/test_quic.py +0 -0
  205. {letify-2.8.0 → letify-2.8.1}/tests/test_remoting.py +0 -0
  206. {letify-2.8.0 → letify-2.8.1}/tests/test_render.py +0 -0
  207. {letify-2.8.0 → letify-2.8.1}/tests/test_runtime.py +0 -0
  208. {letify-2.8.0 → letify-2.8.1}/tests/test_ship.py +0 -0
  209. {letify-2.8.0 → letify-2.8.1}/tests/test_store.py +0 -0
  210. {letify-2.8.0 → letify-2.8.1}/tests/test_strategies.py +0 -0
  211. {letify-2.8.0 → letify-2.8.1}/tests/test_stubs.py +0 -0
  212. {letify-2.8.0 → letify-2.8.1}/tests/test_transport.py +0 -0
  213. {letify-2.8.0 → letify-2.8.1}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.8.0 → letify-2.8.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.8.0
3
+ Version: 2.8.1
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
@@ -1565,6 +1565,8 @@ Both run in two places with the same result:
1565
1565
 
1566
1566
  A body run on a one-shot channel, which has no stream back to the local process, cannot ask for a grant, so `stash` and `unstash` there raise `UnsupportedMode` naming the provider rather than reading the runtime's own configuration.
1567
1567
 
1568
+ The worker sets the environment variable `LETIFY_WORKER_PID` to its process id, so a process the body starts, such as one per GPU, inherits it without the grant hook. `stash` and `unstash` called there raise `UnsupportedMode` naming the handoff, saying the call must be made from the process letify runs the body in and not from a child process it started, and neither reads the configuration nor touches Drive.
1569
+
1568
1570
  When the machine declares no Drive store, both raise `ConfigError` naming `drive_login` and `drive_project`. letify never falls back to another store or to the local disk. A Drive request that fails raises `RuntimeFailure` naming the Drive status and the name, and the function body decides whether to carry on.
1569
1571
 
1570
1572
  ## Environment
@@ -1914,6 +1916,8 @@ The key is per session. letify generates a new ed25519 key pair for each session
1914
1916
 
1915
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.
1916
1918
 
1919
+ **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
+
1917
1921
  **Runtime tools.** A runtime whose rendezvous runs a Python program on the machine, Colab and Kaggle both, installs tailcat lazily when the tailcat strategy requests its endpoint, within that strategy's race thread. The install belongs to the rendezvous and not to one provider, so a provider that leaves it out is a defect rather than a choice: without it the tailcat request carries no path, the remote side runs a bare `tailcat`, and the strategy fails with `FileNotFoundError` on a runtime image that ships no such binary. Target construction performs no remote installation. TCP punch rendezvous never waits for tailcat preparation, even if installation fails or takes its full 180 second timeout. A locally available `tailcat` or configured `tailcat_binary` requires the Linux release pinned by `letify.install.TAILCAT_VERSION`, `TAILCAT_RELEASE` and `TAILCAT_SHA256`. The runtime selects its own architecture, verifies the archive SHA-256, extracts only a regular `tailcat` member into `~/.letify/tools/tailcat/<version>/tailcat`, and verifies the cached binary against its recorded SHA-256 on reuse. Only tailcat requests carry that absolute path. Installation errors name the tool and verification failure and fail that strategy. Tailcat remains eligible because throughput is measured per link rather than inferred from another session. The shared account throughput floor still rejects slow links.
1918
1922
 
1919
1923
  **Rendezvous scheduling.** Provider `colab exec` commands, including TCP punch rendezvous and tailcat preparation, do not acquire the wake lock. The lock protects concurrent wakes sharing CLI session state; rendezvous is a separate operation whose agreed dial time cannot wait behind a wake's 120 second timeout. Wakes acquire the account lock without waiting. If another wake holds it, the daemon records `wake_skipped` with reason `account_busy`, logs the skip and waits 60 seconds before trying again. Successful acquisition still holds the lock through the wake command so two wakes cannot collide. An already running wake cannot hold a lock needed by a connection.
@@ -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.0"
71
+ __version__ = "2.8.1"
72
72
 
73
73
 
74
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -1346,6 +1346,11 @@ _DRIVE_LOCK = threading.Condition()
1346
1346
  #: ``stash`` or ``unstash`` takes them.
1347
1347
  _DRIVE_ANSWERS = {}
1348
1348
 
1349
+ # A process the body starts inherits this and lacks ``_drive_ask``, which is how
1350
+ # ``stash`` and ``unstash`` tell it is a child of the body and refuse. Spec "Named handoffs
1351
+ # on the Drive store".
1352
+ os.environ["LETIFY_WORKER_PID"] = str(os.getpid())
1353
+
1349
1354
 
1350
1355
  def _drive_ask(message):
1351
1356
  """Ask the client for a Drive grant. Nothing replies to this request.
@@ -121,6 +121,28 @@ def _access_token(stored: dict[str, Any]) -> str:
121
121
  return str(json.loads(response.read())["access_token"])
122
122
 
123
123
 
124
+ #: Run in the CLI's own Python: release the server-side assignment at the endpoint in argv.
125
+ #: ``colab stop`` cannot do this, because it needs the local session state an assignment
126
+ #: made by a failed ``colab new`` never got. Spec "Colab", an assignment with no session.
127
+ UNASSIGN_SCRIPT = (
128
+ "import sys\n"
129
+ "from colab_cli.common import state\n"
130
+ "state.client.unassign(sys.argv[1])\n"
131
+ )
132
+
133
+
134
+ def _parse_assignments(listing: str) -> list[tuple[str, str]]:
135
+ """The ``(name, endpoint)`` pairs in ``colab sessions`` output, ``?`` for no local state."""
136
+ found = []
137
+ for line in listing.splitlines():
138
+ head = line.strip().split("|")[0].split()
139
+ if len(head) >= 2 and head[0].startswith("[") and head[0].endswith("]"):
140
+ name = head[0][1:-1]
141
+ if name.lower() != "colab":
142
+ found.append((name, head[1]))
143
+ return found
144
+
145
+
124
146
  class Colab(Shell):
125
147
  """A Colab runtime for one Google account."""
126
148
 
@@ -316,6 +338,68 @@ class Colab(Shell):
316
338
  names.append(word)
317
339
  return names
318
340
 
341
+ def _assignments(self) -> list[tuple[str, str]]:
342
+ """Every assignment the account holds, as ``(name, endpoint)``; ``?`` has no local state."""
343
+ return _parse_assignments(self._cli("sessions", timeout=120))
344
+
345
+ def _unassign(self, endpoint: str) -> None:
346
+ """Release one assignment by endpoint, through the CLI's own client library."""
347
+ uv = tools.find_uv()
348
+ if uv is None:
349
+ raise ProviderUnavailable(self.kind, tools.missing_uv_message())
350
+ command = [*tools.python_command(tools.COLAB, uv), "-c", UNASSIGN_SCRIPT, endpoint]
351
+ result = subprocess.run(
352
+ command, capture_output=True, text=True, timeout=180, env=self._env()
353
+ )
354
+ if result.returncode != 0:
355
+ raise RuntimeFailure(
356
+ f"unassigning {endpoint} exited {result.returncode}",
357
+ command=" ".join(command[:-3]),
358
+ stderr=(result.stderr or "").strip(),
359
+ )
360
+
361
+ def orphans(self) -> list[str]:
362
+ """Endpoints of assignments the account holds that no local session names."""
363
+ return [endpoint for name, endpoint in self._assignments() if name == "?"]
364
+
365
+ def stop_orphan(self, ref: str) -> bool:
366
+ """Unassign one endpoint `orphans` reported, and report whether it is gone."""
367
+ try:
368
+ self._unassign(ref)
369
+ except (RuntimeFailure, ProviderUnavailable, subprocess.TimeoutExpired):
370
+ return False
371
+ return True
372
+
373
+ def _release_leftovers(self, before: set[str]) -> None:
374
+ """Unassign each `?` assignment that appeared since ``before``, best effort."""
375
+ try:
376
+ leftovers = [
377
+ endpoint
378
+ for name, endpoint in self._assignments()
379
+ if name == "?" and endpoint not in before
380
+ ]
381
+ except (RuntimeFailure, ProviderUnavailable, subprocess.TimeoutExpired) as exc:
382
+ print(
383
+ f"letify: {self.alias}: the endpoint check after the failed 'colab new' "
384
+ f"could not list assignments ({exc}); an assignment may still be billing, "
385
+ "see 'letify stop --orphans'",
386
+ file=sys.stderr,
387
+ )
388
+ return
389
+ for endpoint in leftovers:
390
+ if self.stop_orphan(endpoint):
391
+ print(
392
+ f"letify: {self.alias}: released {endpoint}, assigned by the failed "
393
+ "'colab new'",
394
+ file=sys.stderr,
395
+ )
396
+ else:
397
+ print(
398
+ f"letify: {self.alias}: could not release {endpoint}, assigned by the "
399
+ "failed 'colab new'; run 'letify stop --orphans'",
400
+ file=sys.stderr,
401
+ )
402
+
319
403
  # -- the pipeline ----------------------------------------------------------
320
404
 
321
405
  def strategies(self) -> list[Strategy]:
@@ -388,7 +472,22 @@ class Colab(Shell):
388
472
  args += ["--gpu", ALIASES.get(instance.gpu, instance.gpu)]
389
473
  elif instance.tpu:
390
474
  args += ["--tpu", instance.tpu]
391
- self._cli(*args, timeout=900)
475
+ try:
476
+ before = {endpoint for _, endpoint in self._assignments()}
477
+ except (RuntimeFailure, subprocess.TimeoutExpired) as exc:
478
+ before = None
479
+ print(
480
+ f"letify: {self.alias}: the endpoint check before 'colab new' could not "
481
+ f"list assignments ({exc}); a failed creation will not be cleaned up",
482
+ file=sys.stderr,
483
+ )
484
+ try:
485
+ self._cli(*args, timeout=900)
486
+ except BaseException:
487
+ # A timed out `colab new` can still leave a server-side assignment behind.
488
+ if before is not None:
489
+ self._release_leftovers(before)
490
+ raise
392
491
  try:
393
492
  self._start_keep_alive(name)
394
493
  except BaseException:
@@ -35,6 +35,10 @@ from ..protocol import codec
35
35
  #: so a body can tell it runs on a channel with no stream back to the local process.
36
36
  ONE_SHOT_MARKER = "LETIFY_ONE_SHOT"
37
37
 
38
+ #: The environment variable the worker sets to its process id, so a child process of the
39
+ #: body, which inherits it but lacks the worker's grant hook, can say why it cannot ask.
40
+ WORKER_MARKER = "LETIFY_WORKER_PID"
41
+
38
42
  #: The prefix of a handoff's ref, so handoffs share the folder with other refs.
39
43
  REF_SPACE = "stash/"
40
44
 
@@ -198,7 +202,7 @@ def stash(name: str, path: str | os.PathLike[str]) -> str:
198
202
  source = Path(path)
199
203
  if not source.exists():
200
204
  raise FileNotFoundError(f"cannot stash {str(source)!r}: it does not exist")
201
- main = _worker_main()
205
+ main = _worker_main(name)
202
206
  if main is not None:
203
207
  return _stash_in_worker(main, name, source)
204
208
  backend = drive_backend()
@@ -222,7 +226,7 @@ def unstash(name: str, path: str | os.PathLike[str]) -> Path | None:
222
226
  """
223
227
  check_name(name)
224
228
  target = Path(path)
225
- main = _worker_main()
229
+ main = _worker_main(name)
226
230
  if main is not None:
227
231
  return _unstash_in_worker(main, name, target)
228
232
  backend = drive_backend()
@@ -255,7 +259,7 @@ def _verify(name: str, archive: Path, digest: str) -> None:
255
259
  # -- inside a shipped function ---------------------------------------------------------
256
260
 
257
261
 
258
- def _worker_main() -> Any:
262
+ def _worker_main(name: str) -> Any:
259
263
  """The worker's ``__main__`` module when this runs inside a shipped function, else None.
260
264
 
261
265
  The same test ``all_reduce`` makes: only the worker defines the hook that sends a request
@@ -264,6 +268,13 @@ def _worker_main() -> Any:
264
268
  main = sys.modules.get("__main__")
265
269
  if main is not None and hasattr(main, "_drive_ask"):
266
270
  return main
271
+ worker_pid = os.environ.get(WORKER_MARKER)
272
+ if worker_pid is not None and worker_pid != str(os.getpid()):
273
+ raise UnsupportedMode(
274
+ f"handoff {name!r}: stash and unstash must be called from the process letify "
275
+ f"runs the body in, not from a child process it started (this is a child of "
276
+ f"worker process {worker_pid}). Call it in the body and pass the path to the child."
277
+ )
267
278
  one_shot = os.environ.get(ONE_SHOT_MARKER)
268
279
  if one_shot is not None:
269
280
  raise UnsupportedMode(
@@ -19,7 +19,7 @@ from __future__ import annotations
19
19
  import os
20
20
  import shutil
21
21
  import sys
22
- from dataclasses import dataclass
22
+ from dataclasses import dataclass, replace
23
23
  from pathlib import Path
24
24
 
25
25
  from .config.secrets import account_directory
@@ -111,6 +111,15 @@ def command(tool: Tool, uv: str) -> list[str]:
111
111
  ]
112
112
 
113
113
 
114
+ def python_command(tool: Tool, uv: str) -> list[str]:
115
+ """The argument list that runs the Python of ``tool``'s own uv environment.
116
+
117
+ Code run this way can import the tool's package, which is how a call the tool's command
118
+ line does not offer reaches the tool's client library.
119
+ """
120
+ return command(replace(tool, executable="python"), uv)
121
+
122
+
114
123
  def _ensure_symlink(link_path: Path, target: Path) -> None:
115
124
  link_path.parent.mkdir(parents=True, exist_ok=True)
116
125
  if link_path.is_symlink() or link_path.exists():
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.8.0"
141
+ version = "2.8.1"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.8.0"
149
+ version = "2.8.1"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.8.0"
167
+ version = "2.8.1"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.8.0"
11
+ version = "2.8.1"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.8.0",
9
+ "version": "2.8.1",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "devDependencies": {
12
12
  "@types/node": "^18.19.0",
@@ -2,7 +2,7 @@
2
2
  "name": "letify-status",
3
3
  "displayName": "letify Status",
4
4
  "description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
5
- "version": "2.8.0",
5
+ "version": "2.8.1",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.8.0"
3
+ version = "2.8.1"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2016,3 +2016,41 @@ def kaggle_api_token(isolated_home):
2016
2016
  write_secret("kaggle_a", "username", "irack000")
2017
2017
  return write_secret("kaggle_a", "access_token", "KGAT_" + "a" * 32)
2018
2018
 
2019
+
2020
+
2021
+ class FakeColabCli:
2022
+ """A Colab CLI that holds assignments, for ``patch_run`` as the ``result`` callable.
2023
+
2024
+ ``sessions`` prints one ``[<name>] <endpoint>`` row per assignment, ``?`` for one with
2025
+ no local state. ``new`` can add an assignment and then fail, as a read timeout does.
2026
+ The unassign script is recognized by its ``python`` executable and removes the endpoint
2027
+ named by its last argument.
2028
+ """
2029
+
2030
+ def __init__(self, assignments=(), new_adds=None, new_fails=False, list_fails_after=None):
2031
+ self.assignments = list(assignments)
2032
+ self.new_adds = new_adds
2033
+ self.new_fails = new_fails
2034
+ self.list_fails_after = list_fails_after
2035
+ self.listings = 0
2036
+ self.unassigned: list[str] = []
2037
+
2038
+ def __call__(self, command):
2039
+ if "python" in command and "-c" in command:
2040
+ self.unassigned.append(command[-1])
2041
+ self.assignments = [a for a in self.assignments if a[1] != command[-1]]
2042
+ return FakeCompleted()
2043
+ if "sessions" in command:
2044
+ self.listings += 1
2045
+ if self.list_fails_after is not None and self.listings > self.list_fails_after:
2046
+ return FakeCompleted(returncode=1, stderr="Read timed out")
2047
+ rows = [f"[{n}] {e} | Hardware: A100 | Variant: GPU\n" for n, e in self.assignments]
2048
+ return FakeCompleted(
2049
+ stdout="".join(rows) or "[colab] No active sessions found on server.\n"
2050
+ )
2051
+ if "new" in command:
2052
+ if self.new_adds:
2053
+ self.assignments.append(("?", self.new_adds))
2054
+ if self.new_fails:
2055
+ return FakeCompleted(returncode=1, stderr="ReadTimeout: Read timed out")
2056
+ return FakeCompleted()
@@ -425,3 +425,77 @@ def test_stash_and_unstash_on_a_one_shot_channel_raise_unsupported_mode_naming_t
425
425
  assert len(value) == 2
426
426
  assert all("colab_a" in message for message in value)
427
427
  assert fake_drive.requests == []
428
+
429
+
430
+ # -- a child process of the body -------------------------------------------------------
431
+
432
+
433
+ def test_stash_and_unstash_in_a_child_process_of_the_body_say_to_call_from_the_main_process(
434
+ fake_drive, launcher_from, tmp_path, worker_sees_this_checkout
435
+ ) -> None:
436
+ """Spec: a child the body starts has no grant hook, so it raises instead of reading config."""
437
+ declare_drive(fake_drive)
438
+ let = launcher_from(LOCAL)
439
+ source = tmp_path / "f.txt"
440
+ source.write_text("x")
441
+
442
+ @let.function(device=let.providers.a.CPU, host=letify.remote)
443
+ def from_children(path: str) -> dict:
444
+ import multiprocessing
445
+ import os
446
+ import subprocess
447
+ import sys
448
+ from pathlib import Path
449
+
450
+ import letify as inner
451
+
452
+ code = (
453
+ "import sys, letify\n"
454
+ "for call in (lambda: letify.stash('kid', sys.argv[1]),"
455
+ " lambda: letify.unstash('kid', sys.argv[1] + '.out')):\n"
456
+ " try:\n"
457
+ " call()\n"
458
+ " except letify.UnsupportedMode as exc:\n"
459
+ " print('UM:' + str(exc))\n"
460
+ )
461
+ run = subprocess.run(
462
+ [sys.executable, "-c", code, path], capture_output=True, text=True, check=True
463
+ )
464
+ helper = Path(path).parent / "spawned_helper.py"
465
+ helper.write_text(
466
+ "import letify\n"
467
+ "def unstash_it(queue, path):\n"
468
+ " try:\n"
469
+ " letify.unstash('spawned', path)\n"
470
+ " except letify.UnsupportedMode as exc:\n"
471
+ " queue.put(str(exc))\n"
472
+ " else:\n"
473
+ " queue.put('no error')\n"
474
+ )
475
+ sys.path.insert(0, str(helper.parent))
476
+ old = os.environ.get("PYTHONPATH", "")
477
+ os.environ["PYTHONPATH"] = str(helper.parent) + os.pathsep + old
478
+ import spawned_helper
479
+
480
+ context = multiprocessing.get_context("spawn")
481
+ queue = context.Queue()
482
+ process = context.Process(target=spawned_helper.unstash_it, args=(queue, path + ".spawn"))
483
+ process.start()
484
+ spawned = queue.get(timeout=120)
485
+ process.join()
486
+ return {
487
+ "subprocess": [x for x in run.stdout.splitlines() if x.startswith("UM:")],
488
+ "spawn": spawned,
489
+ "main_is_not_refused": inner.UnsupportedMode.__name__,
490
+ }
491
+
492
+ with let.keep_alive():
493
+ answer = from_children(str(source))
494
+ assert len(answer["subprocess"]) == 2
495
+ for message in answer["subprocess"] + [answer["spawn"]]:
496
+ assert "process letify runs the body in" in message
497
+ assert "child process" in message
498
+ assert "'kid'" in answer["subprocess"][0] and "'kid'" in answer["subprocess"][1]
499
+ assert "'spawned'" in answer["spawn"]
500
+ assert fake_drive.requests == []
501
+
@@ -20,7 +20,7 @@ from importlib import import_module
20
20
  from pathlib import Path
21
21
 
22
22
  import pytest
23
- from conftest import FakeCompleted, FakeResponse, provider_of
23
+ from conftest import FakeColabCli, FakeCompleted, FakeResponse, provider_of
24
24
 
25
25
  import letify
26
26
  from letify import providers
@@ -3066,3 +3066,73 @@ def test_a_data_channel_failure_names_the_host(isolated_home, fake_modal, capfd)
3066
3066
  finally:
3067
3067
  channel.close()
3068
3068
  provider.stop(runtime)
3069
+
3070
+
3071
+ # -- Spec: Colab, an assignment with no session ---------------------------------
3072
+
3073
+
3074
+ def _colab_with(patch_which, patch_run, cli):
3075
+ patch_which(tools_module, present=True)
3076
+ patch_run(colab_module, result=cli)
3077
+ return provider_of(Colab, "colab_a")
3078
+
3079
+
3080
+ def test_a_failed_colab_new_that_left_an_assignment_unassigns_it_and_reraises(
3081
+ isolated_home, patch_which, patch_run, capsys
3082
+ ) -> None:
3083
+ cli = FakeColabCli(new_adds="gpu-a100-new", new_fails=True)
3084
+ provider = _colab_with(patch_which, patch_run, cli)
3085
+ with pytest.raises(letify.RuntimeFailure, match=r"ReadTimeout|exited 1"):
3086
+ provider.create_session(provider.A100, "letify-a100-1")
3087
+ assert cli.unassigned == ["gpu-a100-new"]
3088
+ assert "gpu-a100-new" in capsys.readouterr().err
3089
+
3090
+
3091
+ def test_an_assignment_that_existed_before_a_failed_colab_new_is_left_alone(
3092
+ isolated_home, patch_which, patch_run
3093
+ ) -> None:
3094
+ cli = FakeColabCli(assignments=[("?", "gpu-old")], new_fails=True)
3095
+ provider = _colab_with(patch_which, patch_run, cli)
3096
+ with pytest.raises(letify.RuntimeFailure):
3097
+ provider.create_session(provider.A100, "letify-a100-1")
3098
+ assert cli.unassigned == []
3099
+
3100
+
3101
+ def test_a_failed_colab_new_does_not_unassign_a_session_with_a_name(
3102
+ isolated_home, patch_which, patch_run
3103
+ ) -> None:
3104
+ cli = FakeColabCli(new_fails=True)
3105
+ cli.assignments.append(("letify-other", "gpu-named"))
3106
+ provider = _colab_with(patch_which, patch_run, cli)
3107
+ with pytest.raises(letify.RuntimeFailure):
3108
+ provider.create_session(provider.A100, "letify-a100-1")
3109
+ assert cli.unassigned == []
3110
+
3111
+
3112
+ def test_a_failed_endpoint_check_is_reported_and_the_original_error_is_raised(
3113
+ isolated_home, patch_which, patch_run, capsys
3114
+ ) -> None:
3115
+ cli = FakeColabCli(new_fails=True, list_fails_after=1)
3116
+ provider = _colab_with(patch_which, patch_run, cli)
3117
+ with pytest.raises(letify.RuntimeFailure, match=r"colab_a|new"):
3118
+ provider.create_session(provider.A100, "letify-a100-1")
3119
+ assert "endpoint check" in capsys.readouterr().err
3120
+ assert cli.unassigned == []
3121
+
3122
+
3123
+ def test_colab_orphans_are_the_endpoints_with_no_local_session(
3124
+ isolated_home, patch_which, patch_run
3125
+ ) -> None:
3126
+ cli = FakeColabCli(assignments=[("?", "gpu-a"), ("letify-x", "gpu-b"), ("?", "gpu-c")])
3127
+ provider = _colab_with(patch_which, patch_run, cli)
3128
+ assert provider.orphans() == ["gpu-a", "gpu-c"]
3129
+
3130
+
3131
+ def test_stopping_a_colab_orphan_unassigns_its_endpoint(
3132
+ isolated_home, patch_which, patch_run
3133
+ ) -> None:
3134
+ cli = FakeColabCli(assignments=[("?", "gpu-a")])
3135
+ provider = _colab_with(patch_which, patch_run, cli)
3136
+ assert provider.stop_orphan("gpu-a") is True
3137
+ assert cli.unassigned == ["gpu-a"]
3138
+ assert provider.orphans() == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes