omnirun 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {omnirun-0.2.1 → omnirun-0.2.3}/.github/workflows/publish.yml +3 -0
  2. {omnirun-0.2.1 → omnirun-0.2.3}/PKG-INFO +5 -2
  3. {omnirun-0.2.1 → omnirun-0.2.3}/README.md +4 -1
  4. {omnirun-0.2.1 → omnirun-0.2.3}/flake.nix +3 -0
  5. {omnirun-0.2.1 → omnirun-0.2.3}/pyproject.toml +1 -1
  6. omnirun-0.2.3/scripts/check-version.sh +32 -0
  7. omnirun-0.2.3/scripts/pre-push +39 -0
  8. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/__init__.py +1 -1
  9. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/base.py +18 -1
  10. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/colab.py +10 -3
  11. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/kaggle.py +10 -3
  12. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/local.py +11 -3
  13. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/marketplace.py +63 -13
  14. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/slurm.py +11 -4
  15. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/ssh.py +8 -3
  16. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/vast.py +15 -9
  17. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/cli.py +18 -8
  18. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/config.py +13 -2
  19. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/daemon.py +16 -9
  20. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/repo.py +62 -0
  21. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_cli.py +77 -3
  22. omnirun-0.2.3/tests/test_config.py +37 -0
  23. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_marketplaces.py +114 -1
  24. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_queue.py +7 -2
  25. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_repo.py +52 -1
  26. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_slurm.py +26 -0
  27. {omnirun-0.2.1 → omnirun-0.2.3}/uv.lock +1 -1
  28. {omnirun-0.2.1 → omnirun-0.2.3}/.envrc +0 -0
  29. {omnirun-0.2.1 → omnirun-0.2.3}/.github/workflows/checks.yml +0 -0
  30. {omnirun-0.2.1 → omnirun-0.2.3}/.github/workflows/ci.yml +0 -0
  31. {omnirun-0.2.1 → omnirun-0.2.3}/.gitignore +0 -0
  32. {omnirun-0.2.1 → omnirun-0.2.3}/.python-version +0 -0
  33. {omnirun-0.2.1 → omnirun-0.2.3}/AGENTS.md +0 -0
  34. {omnirun-0.2.1 → omnirun-0.2.3}/CLAUDE.md +0 -0
  35. {omnirun-0.2.1 → omnirun-0.2.3}/DESIGN.md +0 -0
  36. {omnirun-0.2.1 → omnirun-0.2.3}/TESTING.md +0 -0
  37. {omnirun-0.2.1 → omnirun-0.2.3}/flake.lock +0 -0
  38. {omnirun-0.2.1 → omnirun-0.2.3}/main.py +0 -0
  39. {omnirun-0.2.1 → omnirun-0.2.3}/research/colab-kaggle.md +0 -0
  40. {omnirun-0.2.1 → omnirun-0.2.3}/research/gpu-marketplaces.md +0 -0
  41. {omnirun-0.2.1 → omnirun-0.2.3}/research/launcher-landscape.md +0 -0
  42. {omnirun-0.2.1 → omnirun-0.2.3}/research/ml-job-queues.md +0 -0
  43. {omnirun-0.2.1 → omnirun-0.2.3}/research/slurm-ssh.md +0 -0
  44. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/__init__.py +0 -0
  45. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/jobdir.py +0 -0
  46. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/runpod.py +0 -0
  47. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/tarsafe.py +0 -0
  48. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/thunder.py +0 -0
  49. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/bootstrap.py +0 -0
  50. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/chooser.py +0 -0
  51. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/__init__.py +0 -0
  52. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/base.py +0 -0
  53. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/local.py +0 -0
  54. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/ssh.py +0 -0
  55. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/models.py +0 -0
  56. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/queue.py +0 -0
  57. {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/store.py +0 -0
  58. {omnirun-0.2.1 → omnirun-0.2.3}/tests/__init__.py +0 -0
  59. {omnirun-0.2.1 → omnirun-0.2.3}/tests/conftest.py +0 -0
  60. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_bootstrap.py +0 -0
  61. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_chooser.py +0 -0
  62. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_colab.py +0 -0
  63. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_kaggle.py +0 -0
  64. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_local_backend.py +0 -0
  65. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_ssh_backend.py +0 -0
  66. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_ssh_exec.py +0 -0
  67. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_store.py +0 -0
  68. {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_tarsafe.py +0 -0
@@ -22,6 +22,9 @@ jobs:
22
22
  - name: Checkout
23
23
  uses: actions/checkout@v7
24
24
 
25
+ - name: Check tag matches package version
26
+ run: ./scripts/check-version.sh "${GITHUB_REF_NAME}"
27
+
25
28
  - name: Install uv
26
29
  uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
27
30
  with:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnirun
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Run jobs from your repo anywhere: Slurm over SSH, any SSH box, Kaggle, Colab, or marketplace GPUs
5
5
  Author-email: Dmitrii Mukhutdinov <flyingleafe@gmail.com>
6
6
  License: MIT
@@ -140,7 +140,9 @@ section name is yours (you can have several of the same type). All backends
140
140
  accept `enabled = false` to keep the config without probing it. Every backend
141
141
  also accepts `project_root` (point it at an existing checkout to reuse its
142
142
  `.git`/`.venv`) and `max_parallel` (queue-daemon cap on concurrent jobs, default
143
- `1`).
143
+ `1`). `project_root` may be a single path (applied to every repo) or a table
144
+ mapping repo slug -> path, with an optional `default` key as fallback, so one
145
+ backend can serve several repos (each getting the right checkout).
144
146
 
145
147
  ```toml
146
148
  [policy]
@@ -159,6 +161,7 @@ poll_interval_s = 10.0 # scheduler tick: refresh running + place pending
159
161
  type = "local"
160
162
  # root = "$HOME/.omnirun" # OMNIRUN_ROOT: where jobs live
161
163
  # project_root = "$HOME/proj" # reuse an existing checkout's .git and .venv
164
+ # project_root = { my-repo = "$HOME/proj" } # or per-repo (by slug), else default path
162
165
  # max_parallel = 2 # queue daemon: run up to 2 jobs here at once
163
166
 
164
167
  # ---- any machine you can ssh into ----
@@ -120,7 +120,9 @@ section name is yours (you can have several of the same type). All backends
120
120
  accept `enabled = false` to keep the config without probing it. Every backend
121
121
  also accepts `project_root` (point it at an existing checkout to reuse its
122
122
  `.git`/`.venv`) and `max_parallel` (queue-daemon cap on concurrent jobs, default
123
- `1`).
123
+ `1`). `project_root` may be a single path (applied to every repo) or a table
124
+ mapping repo slug -> path, with an optional `default` key as fallback, so one
125
+ backend can serve several repos (each getting the right checkout).
124
126
 
125
127
  ```toml
126
128
  [policy]
@@ -139,6 +141,7 @@ poll_interval_s = 10.0 # scheduler tick: refresh running + place pending
139
141
  type = "local"
140
142
  # root = "$HOME/.omnirun" # OMNIRUN_ROOT: where jobs live
141
143
  # project_root = "$HOME/proj" # reuse an existing checkout's .git and .venv
144
+ # project_root = { my-repo = "$HOME/proj" } # or per-repo (by slug), else default path
142
145
  # max_parallel = 2 # queue daemon: run up to 2 jobs here at once
143
146
 
144
147
  # ---- any machine you can ssh into ----
@@ -71,6 +71,9 @@
71
71
 
72
72
  shellHook = ''
73
73
  ${pre-commit-check.shellHook}
74
+ # Guard against tagging a release without bumping the version.
75
+ # pre-commit owns only the pre-commit hook, so pre-push is ours.
76
+ ln -sf "$PWD/scripts/pre-push" "$(git rev-parse --git-path hooks)/pre-push"
74
77
  export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.zlib ]}:$LD_LIBRARY_PATH
75
78
  export UV_PYTHON=${python}/bin/python
76
79
  uv sync --quiet
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "omnirun"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Run jobs from your repo anywhere: Slurm over SSH, any SSH box, Kaggle, Colab, or marketplace GPUs"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # Verify the package version is internally consistent, and optionally that it
3
+ # matches an expected version (e.g. a release tag).
4
+ #
5
+ # scripts/check-version.sh # pyproject.toml == src/omnirun/__init__.py
6
+ # scripts/check-version.sh v0.2.2 # ...and both equal 0.2.2
7
+ #
8
+ # Used by both the publish workflow and the pre-push hook so the rule lives in
9
+ # one place.
10
+ set -euo pipefail
11
+
12
+ root="$(cd "$(dirname "$0")/.." && pwd)"
13
+
14
+ extract() { grep -m1 -E "$2" "$root/$1" | sed -E 's/.*"([^"]+)".*/\1/'; }
15
+
16
+ pyproject="$(extract pyproject.toml '^version *= *"')"
17
+ init="$(extract src/omnirun/__init__.py '^__version__ *= *"')"
18
+
19
+ if [ "$pyproject" != "$init" ]; then
20
+ echo "version mismatch: pyproject.toml=$pyproject src/omnirun/__init__.py=$init" >&2
21
+ exit 1
22
+ fi
23
+
24
+ if [ "$#" -ge 1 ]; then
25
+ expected="${1#v}"
26
+ if [ "$pyproject" != "$expected" ]; then
27
+ echo "version mismatch: expected $expected but package version is $pyproject" >&2
28
+ exit 1
29
+ fi
30
+ fi
31
+
32
+ echo "version OK: $pyproject"
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # Refuse to push a release tag whose commit does not declare a matching version.
3
+ #
4
+ # Installed as .git/hooks/pre-push by the devShell (see flake.nix). It inspects
5
+ # the *pushed commit's* content (via `git show <sha>:path`), so it catches a
6
+ # stale worktree too. CI (publish.yml) enforces the same rule server-side.
7
+ set -euo pipefail
8
+
9
+ version_at() { git show "$1:$2" | grep -m1 -E "$3" | sed -E 's/.*"([^"]+)".*/\1/'; }
10
+
11
+ status=0
12
+ while read -r _local_ref local_sha remote_ref _remote_sha; do
13
+ case "$remote_ref" in
14
+ refs/tags/v[0-9]*) ;;
15
+ *) continue ;;
16
+ esac
17
+ # Deleting a tag pushes the zero sha; nothing to check.
18
+ case "$local_sha" in
19
+ 0000000000000000000000000000000000000000) continue ;;
20
+ esac
21
+
22
+ tag="${remote_ref#refs/tags/}"
23
+ pyproject="$(version_at "$local_sha" pyproject.toml '^version *= *"')"
24
+ init="$(version_at "$local_sha" src/omnirun/__init__.py '^__version__ *= *"')"
25
+
26
+ if [ "$tag" != "v$pyproject" ]; then
27
+ echo "pre-push: tag $tag != pyproject version $pyproject (commit ${local_sha:0:12})" >&2
28
+ status=1
29
+ fi
30
+ if [ "$pyproject" != "$init" ]; then
31
+ echo "pre-push: pyproject $pyproject != __init__ $init (commit ${local_sha:0:12})" >&2
32
+ status=1
33
+ fi
34
+ done
35
+
36
+ if [ "$status" -ne 0 ]; then
37
+ echo "pre-push: bump the version before tagging (see scripts/check-version.sh)" >&2
38
+ fi
39
+ exit "$status"
@@ -1,3 +1,3 @@
1
1
  """omnirun — run jobs from your repo anywhere."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
@@ -22,6 +22,14 @@ class BackendError(RuntimeError):
22
22
  """Raised for backend-level failures with a user-actionable message."""
23
23
 
24
24
 
25
+ #: Optional hook submit() calls the instant it creates a *billable* resource,
26
+ #: before the (possibly minutes-long) wait for it to become usable. It hands the
27
+ #: client a partial JobHandle so a recovery stub can be persisted immediately —
28
+ #: an interrupted submit then stays visible to `ps`/`gc` instead of orphaning a
29
+ #: running instance with no local record.
30
+ ProvisioningSink = Callable[[JobHandle], None]
31
+
32
+
25
33
  class Backend(ABC):
26
34
  """One configured execution target (a cluster, a machine, a provider account).
27
35
 
@@ -46,7 +54,16 @@ class Backend(ABC):
46
54
  def probe(self, res: ResourceSpec) -> list[Offer]: ...
47
55
 
48
56
  @abstractmethod
49
- def submit(self, spec: JobSpec, offer: Offer) -> JobHandle: ...
57
+ def submit(
58
+ self,
59
+ spec: JobSpec,
60
+ offer: Offer,
61
+ on_provisioning: ProvisioningSink | None = None,
62
+ ) -> JobHandle:
63
+ """Run the job and return a handle. If a billable resource is created
64
+ before the handle is ready, call on_provisioning with a partial handle
65
+ first so the client can persist a recovery stub (see ProvisioningSink)."""
66
+ ...
50
67
 
51
68
  @abstractmethod
52
69
  def status(self, handle: JobHandle) -> StatusReport: ...
@@ -33,7 +33,7 @@ from pathlib import Path
33
33
  from typing import Any
34
34
 
35
35
  from omnirun.backends import jobdir, tarsafe
36
- from omnirun.backends.base import Backend, BackendError, register
36
+ from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
37
37
  from omnirun.bootstrap import (
38
38
  HEARTBEAT_STALE_S,
39
39
  BootstrapParams,
@@ -329,7 +329,12 @@ class ColabBackend(Backend):
329
329
 
330
330
  # ---- submit -----------------------------------------------------------------
331
331
 
332
- def submit(self, spec: JobSpec, offer: Offer) -> JobHandle:
332
+ def submit(
333
+ self,
334
+ spec: JobSpec,
335
+ offer: Offer,
336
+ on_provisioning: ProvisioningSink | None = None,
337
+ ) -> JobHandle:
333
338
  session = self._session(spec.job_id)
334
339
  job_dir = f"{COLAB_ROOT}/jobs/{spec.job_id}"
335
340
 
@@ -360,7 +365,9 @@ class ColabBackend(Backend):
360
365
  BootstrapParams(
361
366
  omnirun_root=COLAB_ROOT,
362
367
  project_root=jobdir.project_root_of(
363
- COLAB_ROOT, spec.repo.slug, self.config.project_root
368
+ COLAB_ROOT,
369
+ spec.repo.slug,
370
+ self.config.project_root_for(spec.repo.slug),
364
371
  ),
365
372
  code=code,
366
373
  ),
@@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone
33
33
  from pathlib import Path
34
34
  from typing import Any
35
35
 
36
- from omnirun.backends.base import Backend, BackendError, register
36
+ from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
37
37
  from omnirun.backends import jobdir, tarsafe
38
38
  from omnirun.bootstrap import (
39
39
  BootstrapParams,
@@ -393,7 +393,12 @@ class KaggleBackend(Backend):
393
393
  "the backend's `max_source_bytes` if Kaggle accepts more"
394
394
  )
395
395
 
396
- def submit(self, spec: JobSpec, offer: Offer) -> JobHandle:
396
+ def submit(
397
+ self,
398
+ spec: JobSpec,
399
+ offer: Offer,
400
+ on_provisioning: ProvisioningSink | None = None,
401
+ ) -> JobHandle:
397
402
  api = self._api()
398
403
  user = self._username(api)
399
404
  job_id = spec.job_id
@@ -435,7 +440,9 @@ class KaggleBackend(Backend):
435
440
  BootstrapParams(
436
441
  omnirun_root=KAGGLE_ROOT,
437
442
  project_root=jobdir.project_root_of(
438
- KAGGLE_ROOT, spec.repo.slug, self.config.project_root
443
+ KAGGLE_ROOT,
444
+ spec.repo.slug,
445
+ self.config.project_root_for(spec.repo.slug),
439
446
  ),
440
447
  code=code,
441
448
  ),
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  from typing import TYPE_CHECKING
14
14
 
15
15
  from omnirun.backends import jobdir
16
- from omnirun.backends.base import Backend, register
16
+ from omnirun.backends.base import Backend, ProvisioningSink, register
17
17
  from omnirun.bootstrap import BootstrapParams
18
18
  from omnirun.execlayer.base import shell_quote
19
19
  from omnirun.execlayer.local import LocalExec
@@ -110,10 +110,18 @@ class LocalBackend(Backend):
110
110
 
111
111
  # --- lifecycle ---------------------------------------------------------
112
112
 
113
- def submit(self, spec: JobSpec, offer: Offer) -> JobHandle:
113
+ def submit(
114
+ self,
115
+ spec: JobSpec,
116
+ offer: Offer,
117
+ on_provisioning: ProvisioningSink | None = None,
118
+ ) -> JobHandle:
114
119
  root = jobdir.remote_root(self.exec, self.config.root)
115
120
  project_root = jobdir.resolve_project_root(
116
- self.exec, root, spec.repo.slug, self.config.project_root
121
+ self.exec,
122
+ root,
123
+ spec.repo.slug,
124
+ self.config.project_root_for(spec.repo.slug),
117
125
  )
118
126
  params = BootstrapParams(
119
127
  omnirun_root=root,
@@ -7,6 +7,13 @@ the job (client-side git push + bootstrap.sh) -> detached launch (setsid+nohup,
7
7
  pidfile), exactly like the plain ssh backend.
8
8
 
9
9
  Termination policy (billable resources must not leak):
10
+ - **Provisioning stub** (issue #7): the instant ``submit`` rents an instance —
11
+ before the minutes-long provisioning wait — it calls the ``on_provisioning``
12
+ hook with a partial handle carrying the instance id, so the client persists a
13
+ recovery record *first*. A submit killed mid-wait then stays visible to
14
+ ``ps``/``status`` (reported as PROVISIONING with a billing warning) and
15
+ reclaimable by ``omnirun gc --all``, instead of orphaning a running instance
16
+ with no local trace. The final handle overwrites the stub on success.
10
17
  - **Client-side auto-terminate** (``auto_terminate``, default true): the instance
11
18
  is destroyed in ``pull_outputs`` (after a successful pull), in ``cancel``, and
12
19
  in ``gc``. ``status`` never terminates — when the job is terminal it appends a
@@ -52,7 +59,7 @@ import httpx
52
59
  from pydantic import BaseModel, Field
53
60
 
54
61
  from omnirun.backends import jobdir
55
- from omnirun.backends.base import Backend, BackendError
62
+ from omnirun.backends.base import Backend, BackendError, ProvisioningSink
56
63
  from omnirun.bootstrap import BootstrapParams
57
64
  from omnirun.execlayer.base import Exec, shell_quote
58
65
  from omnirun.repo import local_root_of
@@ -270,9 +277,26 @@ class MarketplaceBackend(Backend, ABC):
270
277
  unfit_reasons=[reason],
271
278
  )
272
279
 
273
- def submit(self, spec: JobSpec, offer: Offer) -> JobHandle:
280
+ def submit(
281
+ self,
282
+ spec: JobSpec,
283
+ offer: Offer,
284
+ on_provisioning: ProvisioningSink | None = None,
285
+ ) -> JobHandle:
274
286
  inst = self._create_instance(spec, offer)
275
287
  instance_id = inst.instance_id
288
+ # The instance is now billing but the handle isn't ready yet. Hand the
289
+ # client a stub carrying the instance id *before* the provisioning wait,
290
+ # so an interrupted submit (a kill mid-wait) stays visible to ps/gc
291
+ # instead of orphaning a running instance with no local record (#7).
292
+ if on_provisioning is not None:
293
+ on_provisioning(
294
+ JobHandle(
295
+ backend=self.name,
296
+ job_id=spec.job_id,
297
+ data={"instance_id": instance_id, "provisioning": True},
298
+ )
299
+ )
276
300
  try:
277
301
  inst = self._wait_provisioned(instance_id)
278
302
  assert inst.ssh_target is not None
@@ -286,7 +310,7 @@ class MarketplaceBackend(Backend, ABC):
286
310
  self._wait_ssh(ex)
287
311
  root = jobdir.remote_root(ex, self.config.root)
288
312
  project_root = jobdir.resolve_project_root(
289
- ex, root, spec.repo.slug, self.config.project_root
313
+ ex, root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
290
314
  )
291
315
  params = BootstrapParams(
292
316
  omnirun_root=root,
@@ -379,6 +403,31 @@ class MarketplaceBackend(Backend, ABC):
379
403
 
380
404
  def status(self, handle: JobHandle) -> StatusReport:
381
405
  instance_id = handle.data["instance_id"]
406
+ if not handle.data.get("job_dir"):
407
+ # A provisioning stub: submit was interrupted before it staged the
408
+ # job, so only the instance id was persisted. Report whether the
409
+ # rented instance is still billing so the user knows to reclaim it.
410
+ try:
411
+ inst = self._get_instance(instance_id)
412
+ except BackendError as e:
413
+ return StatusReport(
414
+ status=JobStatus.PROVISIONING,
415
+ detail=f"submit was interrupted while provisioning instance "
416
+ f"{instance_id}; could not reach the {self.name} API to check "
417
+ f"it ({e}) — verify in the provider console, run `omnirun gc`",
418
+ )
419
+ if inst is None:
420
+ return StatusReport(
421
+ status=JobStatus.LOST,
422
+ detail=f"instance {instance_id} never finished provisioning and "
423
+ "no longer exists",
424
+ )
425
+ return StatusReport(
426
+ status=JobStatus.PROVISIONING,
427
+ detail=f"submit was interrupted after renting instance {instance_id} "
428
+ f"(now {inst.status or 'unknown'}) — it is still billing; run "
429
+ f"`omnirun gc --all` (or cancel {handle.job_id}) to destroy it",
430
+ )
382
431
  inst: Instance | None = None
383
432
  api_error: str | None = None
384
433
  try:
@@ -436,16 +485,17 @@ class MarketplaceBackend(Backend, ABC):
436
485
  return paths
437
486
 
438
487
  def cancel(self, handle: JobHandle) -> None:
439
- try: # best-effort remote kill; instance dies right after anyway
440
- ex = self._exec_from_handle(handle)
441
- q = shell_quote(handle.data["job_dir"])
442
- ex.run(
443
- f"if [ -f {q}/pid ]; then p=$(cat {q}/pid); "
444
- f'kill -TERM -- "-$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null; fi; true',
445
- timeout=30,
446
- )
447
- except Exception:
448
- pass
488
+ if handle.data.get("job_dir"): # a provisioning stub has nothing to kill
489
+ try: # best-effort remote kill; instance dies right after anyway
490
+ ex = self._exec_from_handle(handle)
491
+ q = shell_quote(handle.data["job_dir"])
492
+ ex.run(
493
+ f"if [ -f {q}/pid ]; then p=$(cat {q}/pid); "
494
+ f'kill -TERM -- "-$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null; fi; true',
495
+ timeout=30,
496
+ )
497
+ except Exception:
498
+ pass
449
499
  instance_id = handle.data["instance_id"]
450
500
  if self._get_instance(instance_id) is not None:
451
501
  self._terminate(instance_id)
@@ -17,7 +17,7 @@ from datetime import datetime, timedelta
17
17
  from pathlib import Path
18
18
 
19
19
  from omnirun.backends import jobdir
20
- from omnirun.backends.base import Backend, BackendError, register
20
+ from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
21
21
  from omnirun.bootstrap import BootstrapParams, generate_bootstrap
22
22
  from omnirun.config import BackendConfig
23
23
  from omnirun.execlayer.base import Exec, ExecError, shell_quote
@@ -181,6 +181,8 @@ class SlurmBackend(Backend):
181
181
 
182
182
  reasons: list[str] = []
183
183
  notes: list[str] = []
184
+ if res.wants_gpu() and self.config.extra("has_gpus", True) is False:
185
+ reasons.append(f"backend {self.name} is CPU-only (has_gpus = false)")
184
186
  if res.wants_gpu() and res.gpu_type and self.config.gpu_map:
185
187
  if _gpu_map_lookup(self.config.gpu_map, res.gpu_type) is None:
186
188
  reasons.append(
@@ -258,7 +260,7 @@ class SlurmBackend(Backend):
258
260
  root = self.config.root
259
261
  job_dir = jobdir.job_dir_of(root, spec.job_id)
260
262
  project_root = jobdir.project_root_of(
261
- root, spec.repo.slug, self.config.project_root
263
+ root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
262
264
  )
263
265
  sbatch = render_sbatch(spec, self.config, job_dir, root)
264
266
  bootstrap = generate_bootstrap(
@@ -277,11 +279,16 @@ class SlurmBackend(Backend):
277
279
  )
278
280
  return f"{sbatch}{sep}{bootstrap}"
279
281
 
280
- def submit(self, spec: JobSpec, offer: Offer | None = None) -> JobHandle:
282
+ def submit(
283
+ self,
284
+ spec: JobSpec,
285
+ offer: Offer | None = None,
286
+ on_provisioning: ProvisioningSink | None = None,
287
+ ) -> JobHandle:
281
288
  ex = self.exec_
282
289
  root = jobdir.remote_root(ex, self.config.root)
283
290
  project_root = jobdir.resolve_project_root(
284
- ex, root, spec.repo.slug, self.config.project_root
291
+ ex, root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
285
292
  )
286
293
  params = BootstrapParams(
287
294
  omnirun_root=root,
@@ -10,7 +10,7 @@ from collections.abc import Iterator
10
10
  from pathlib import Path
11
11
 
12
12
  from omnirun.backends import jobdir
13
- from omnirun.backends.base import Backend, BackendError, register
13
+ from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
14
14
  from omnirun.bootstrap import BootstrapParams
15
15
  from omnirun.execlayer.base import Exec, ExecError, shell_quote
16
16
  from omnirun.execlayer.ssh import RECONNECT_HINT, SSHExec
@@ -213,11 +213,16 @@ class SshBackend(Backend):
213
213
 
214
214
  # --- submit / lifecycle ---------------------------------------------------
215
215
 
216
- def submit(self, spec: JobSpec, offer: Offer | None = None) -> JobHandle:
216
+ def submit(
217
+ self,
218
+ spec: JobSpec,
219
+ offer: Offer | None = None,
220
+ on_provisioning: ProvisioningSink | None = None,
221
+ ) -> JobHandle:
217
222
  ex = self.exec_
218
223
  root = jobdir.remote_root(ex, self.config.root)
219
224
  project_root = jobdir.resolve_project_root(
220
- ex, root, spec.repo.slug, self.config.project_root
225
+ ex, root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
221
226
  )
222
227
  params = BootstrapParams(
223
228
  omnirun_root=root,
@@ -37,23 +37,25 @@ OFFER_LIMIT = 5
37
37
  MIN_RELIABILITY = 0.95
38
38
 
39
39
  # normalized name -> vast gpu_name values (their catalog naming)
40
+ # The REST /bundles/ endpoint matches gpu_name with spaces ("A100 SXM4");
41
+ # underscore forms match nothing (the vast CLI translates them, the API doesn't).
40
42
  VAST_GPU_NAMES: dict[str, list[str]] = {
41
43
  "H200": ["H200"],
42
- "H100": ["H100_SXM", "H100_PCIE", "H100_NVL"],
43
- "A100": ["A100_SXM4", "A100_PCIE", "A100X"],
44
- "A100-80": ["A100_SXM4", "A100_PCIE"],
45
- "A6000": ["RTX_A6000"],
44
+ "H100": ["H100 SXM", "H100 PCIE", "H100 NVL"],
45
+ "A100": ["A100 SXM4", "A100 PCIE", "A100X"],
46
+ "A100-80": ["A100 SXM4", "A100 PCIE"],
47
+ "A6000": ["RTX A6000"],
46
48
  "L40": ["L40", "L40S"],
47
49
  "L4": ["L4"],
48
- "V100": ["Tesla_V100"],
49
- "4090": ["RTX_4090"],
50
- "3090": ["RTX_3090"],
51
- "5090": ["RTX_5090"],
50
+ "V100": ["Tesla V100"],
51
+ "4090": ["RTX 4090"],
52
+ "3090": ["RTX 3090"],
53
+ "5090": ["RTX 5090"],
52
54
  }
53
55
 
54
56
 
55
57
  def vast_gpu_names(gpu_type: str) -> list[str]:
56
- return VAST_GPU_NAMES.get(gpu_type, [gpu_type.replace(" ", "_").replace("-", "_")])
58
+ return VAST_GPU_NAMES.get(gpu_type, [gpu_type.replace("_", " ").replace("-", " ")])
57
59
 
58
60
 
59
61
  def normalize_vast_gpu(name: str, gpu_ram_mb: float | None = None) -> str:
@@ -84,6 +86,10 @@ class VastBackend(MarketplaceBackend):
84
86
  }
85
87
  if res.gpu_type is not None:
86
88
  filt["gpu_name"] = {"in": vast_gpu_names(res.gpu_type)}
89
+ if res.gpu_type == "A100-80":
90
+ # cheapest-first + OFFER_LIMIT would otherwise fill the page
91
+ # with 40GB A100s that normalization then discards
92
+ filt["gpu_ram"] = {"gte": 70_000}
87
93
  elif res.min_vram_gb is not None:
88
94
  # vast reports gpu_ram in MB (verify live)
89
95
  filt["gpu_ram"] = {"gte": res.min_vram_gb * 1024}
@@ -27,6 +27,7 @@ from omnirun.config import (
27
27
  )
28
28
  from omnirun.models import (
29
29
  EnvSpec,
30
+ JobHandle,
30
31
  JobRecord,
31
32
  JobSpec,
32
33
  JobStatus,
@@ -451,15 +452,24 @@ def submit(
451
452
  _render_payload(backend_obj, spec, picked.offer)
452
453
  return
453
454
 
454
- handle = backend_obj.submit(spec, picked.offer)
455
- JobStore().save(
456
- JobRecord(
457
- spec=spec,
458
- handle=handle,
459
- offer=picked.offer,
460
- submitted_at=datetime.now(timezone.utc),
455
+ store = JobStore()
456
+ picked_offer = picked.offer
457
+
458
+ def _persist(h: JobHandle) -> None:
459
+ # Called once with a provisioning stub (if the backend rents something
460
+ # before the handle is ready) and again with the final handle, so an
461
+ # interrupted submit still leaves a reclaimable record (#7).
462
+ store.save(
463
+ JobRecord(
464
+ spec=spec,
465
+ handle=h,
466
+ offer=picked_offer,
467
+ submitted_at=datetime.now(timezone.utc),
468
+ )
461
469
  )
462
- )
470
+
471
+ handle = backend_obj.submit(spec, picked_offer, on_provisioning=_persist)
472
+ _persist(handle)
463
473
  console.print(f"[green]submitted[/green] {spec.job_id} -> {picked.offer.label}")
464
474
  console.print(f"follow logs with: omnirun logs -f {spec.job_id}")
465
475
 
@@ -48,8 +48,10 @@ class BackendConfig(BaseModel, extra="allow"):
48
48
  env_setup: list[str] = Field(default_factory=list) # module loads, exports
49
49
  # Where a project's shared checkout + .venv live on the worker. Default
50
50
  # "$OMNIRUN_ROOT/projects/<slug>"; point at an existing clone to reuse it
51
- # (its .git becomes the object store, its .venv the shared env).
52
- project_root: str | None = None
51
+ # (its .git becomes the object store, its .venv the shared env). A str
52
+ # applies to every repo; a dict maps repo slug -> path (with an optional
53
+ # "default" key as fallback) so one backend can serve several repos.
54
+ project_root: str | dict[str, str] | None = None
53
55
 
54
56
  # static capability declaration (ssh backend; probe checks live via nvidia-smi)
55
57
  gpus: list[GpuDecl] = Field(default_factory=list)
@@ -74,6 +76,15 @@ class BackendConfig(BaseModel, extra="allow"):
74
76
  def extra(self, key: str, default: Any = None) -> Any:
75
77
  return (self.model_extra or {}).get(key, default)
76
78
 
79
+ def project_root_for(self, slug: str) -> str | None:
80
+ """Configured project root for a repo. A str applies to every repo; a
81
+ dict is keyed by slug, falling back to a "default" key, then to None
82
+ (the built-in "$OMNIRUN_ROOT/projects/<slug>" path)."""
83
+ pr = self.project_root
84
+ if isinstance(pr, dict):
85
+ return pr.get(slug) or pr.get("default")
86
+ return pr
87
+
77
88
 
78
89
  class DaemonConfig(BaseModel):
79
90
  host: str = "127.0.0.1" # localhost socket; bind 0.0.0.0 + auth to go remote
@@ -26,6 +26,7 @@ from omnirun import chooser
26
26
  from omnirun.backends.base import Backend, make_backend
27
27
  from omnirun.config import Config
28
28
  from omnirun.models import (
29
+ JobHandle,
29
30
  JobRecord,
30
31
  JobSpec,
31
32
  JobStatus,
@@ -433,19 +434,25 @@ class Daemon:
433
434
  if be is None:
434
435
  self._fail_submit(qid, f"backend {backend_name!r} unavailable")
435
436
  return
437
+
438
+ def _persist(h: JobHandle) -> None:
439
+ # Persist a stub the instant a billable resource is created, then
440
+ # the final handle — an interrupted submit stays reclaimable (#7).
441
+ self._jobstore.save(
442
+ JobRecord(
443
+ spec=spec,
444
+ handle=h,
445
+ offer=offer,
446
+ submitted_at=datetime.now(timezone.utc),
447
+ )
448
+ )
449
+
436
450
  try:
437
- handle = be.submit(spec, offer)
451
+ handle = be.submit(spec, offer, on_provisioning=_persist)
438
452
  except Exception as e:
439
453
  self._retry_or_fail(qid, str(e))
440
454
  return
441
- self._jobstore.save(
442
- JobRecord(
443
- spec=spec,
444
- handle=handle,
445
- offer=offer,
446
- submitted_at=datetime.now(timezone.utc),
447
- )
448
- )
455
+ _persist(handle)
449
456
  with self._lock:
450
457
  entry = self._store.get(qid)
451
458
  if entry is None: