hyperstudy-perception 0.1.0__py3-none-any.whl

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 (82) hide show
  1. hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
  2. hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
  3. hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
  4. hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
  5. hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. perception/__init__.py +0 -0
  7. perception/_pipeline/RELEASE.json +59 -0
  8. perception/_pipeline/cloud/runpod/guard.sh +37 -0
  9. perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
  10. perception/_pipeline/cloud/runpod/image/start.sh +11 -0
  11. perception/_pipeline/cloud/runpod/image/tag.py +40 -0
  12. perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
  13. perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
  14. perception/_pipeline/cloud/skypilot/guard.sh +20 -0
  15. perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
  16. perception/_pipeline/cloud/skypilot/launch.sh +63 -0
  17. perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
  18. perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
  19. perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
  20. perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
  21. perception/_pipeline/envs/api/pyproject.toml +12 -0
  22. perception/_pipeline/envs/api/uv.lock +276 -0
  23. perception/_pipeline/envs/audio/pyproject.toml +12 -0
  24. perception/_pipeline/envs/audio/uv.lock +575 -0
  25. perception/_pipeline/envs/core/pyproject.toml +12 -0
  26. perception/_pipeline/envs/core/uv.lock +157 -0
  27. perception/_pipeline/envs/faces/pyproject.toml +12 -0
  28. perception/_pipeline/envs/faces/uv.lock +1180 -0
  29. perception/_pipeline/graphs/decode.yaml +11 -0
  30. perception/_pipeline/graphs/faces.yaml +14 -0
  31. perception/_pipeline/graphs/full.yaml +31 -0
  32. perception/_pipeline/graphs/mvp-captions.yaml +27 -0
  33. perception/_pipeline/graphs/mvp.yaml +17 -0
  34. perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
  35. perception/_pipeline/nodes/audio/asr/node.py +74 -0
  36. perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
  37. perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
  38. perception/_pipeline/nodes/audio/asr/wav.py +19 -0
  39. perception/_pipeline/nodes/conftest.py +64 -0
  40. perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
  41. perception/_pipeline/nodes/core/decode/media.py +104 -0
  42. perception/_pipeline/nodes/core/decode/node.py +56 -0
  43. perception/_pipeline/nodes/core/decode/test_media.py +65 -0
  44. perception/_pipeline/nodes/testdata/README.md +10 -0
  45. perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  46. perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
  47. perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
  48. perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
  49. perception/_pipeline/nodes/vision/face-track/node.py +117 -0
  50. perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
  51. perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
  52. perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
  53. perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
  54. perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
  55. perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
  56. perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
  57. perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
  58. perception/_pipeline/schema/README.md +54 -0
  59. perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
  60. perception/_pipeline/sdk/pyproject.toml +13 -0
  61. perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
  62. perception/artifacts.py +180 -0
  63. perception/backends/__init__.py +0 -0
  64. perception/backends/base.py +179 -0
  65. perception/backends/cloud.py +214 -0
  66. perception/backends/native.py +140 -0
  67. perception/backends/probe.py +44 -0
  68. perception/backends/remote.py +140 -0
  69. perception/cli.py +780 -0
  70. perception/estimates.py +68 -0
  71. perception/export.py +100 -0
  72. perception/hosts.py +201 -0
  73. perception/ir.py +245 -0
  74. perception/keys.py +259 -0
  75. perception/manifests.py +169 -0
  76. perception/plan.py +170 -0
  77. perception/release.py +61 -0
  78. perception/remote_driver.py +990 -0
  79. perception/runpod.py +100 -0
  80. perception/scheduler.py +552 -0
  81. perception/setup.py +72 -0
  82. perception/store.py +219 -0
@@ -0,0 +1,990 @@
1
+ """Runs one attempt of a node on another machine, for the coordinator (backends/remote.py starts it):
2
+
3
+ python -m perception.remote_driver --request ATTEMPT/request.json (with ATTEMPT/remote.json beside it)
4
+
5
+ 1. Copies the node's code (by content: once per version) and its inputs (by sha256: once per host) to the host.
6
+ 2. Writes the request there, with the host's paths, and starts the node in its own session (setsid), detached,
7
+ so a dropped connection does not end it, with a watchdog beside it (WATCHDOG) that stops it and removes its folder
8
+ if no driver asks about it for a while. A previous attempt of the same node still running there is stopped first.
9
+ 3. Watches it every few seconds, appending its log to this attempt's log.txt. A host that cannot be reached is waited
10
+ for (a laptop changes networks) for up to lost_after_s; after that the attempt ends without a response, which the
11
+ coordinator treats as a crash (retried within the run's policy), and the host's watchdog ends the node.
12
+ 4. Brings out/ and response.json back into this attempt's folder, then removes the attempt's folder on the host.
13
+
14
+ SIGTERM (the coordinator's cancel) stops the node on the host before the driver exits. Its output (stdout) is the
15
+ attempt's log.txt.
16
+
17
+ On a cloud machine (spec "cloud", backends/cloud.py) the driver first starts the machine (sky launch, with the
18
+ repository's project config), makes an OS shutdown terminate it, probes it like a host, and runs guard.sh there (the
19
+ perception-node role, no AWS credentials, a shutdown at the deadline) before anything is sent. The node is stopped
20
+ when the deadline nears. Whatever happens, the machine is deleted (sky down) before the driver exits, and the time it
21
+ existed is added to the response's usage.usd (all of the deadline if it could not be deleted).
22
+
23
+ On a RunPod pod (cloud "provider": "runpod") the same steps go through RunPod's API instead: create the pod (the account
24
+ key in this process's environment only), wait for its SSH port, reach it with a key made for the attempt, run
25
+ cloud/runpod/guard.sh there (no key to the account on it, its own removal at the deadline), and delete it at the end.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import contextlib
32
+ import json
33
+ import math
34
+ import os
35
+ import re
36
+ import shlex
37
+ import signal
38
+ import subprocess
39
+ import sys
40
+ import threading
41
+ import time
42
+ from pathlib import Path
43
+
44
+ POLL_S = 3.0
45
+ # exit codes the scripts here use for a refusal that would happen again: the node's error code for each
46
+ REFUSALS = {3: "input", 5: "input"}
47
+ # how long past its deadline a perception pod is left before a RunPod launch deletes it: its own driver deletes it before
48
+ # its deadline, and it removes itself at it (guard.sh)
49
+ SWEEP_GRACE_S = 10 * 60
50
+ KEEP_S = 24 * 3600 # how long a finished node's results wait on the host for a driver that lost touch (WATCHDOG)
51
+
52
+
53
+ class Canceled(Exception):
54
+ pass
55
+
56
+
57
+ class Unreachable(Exception):
58
+ pass
59
+
60
+
61
+ class PrepareFailed(Exception):
62
+ """The node's environment could not be synced, or its prepare script failed. Not retried: these fail the same way
63
+ again (no wheel for the machine, an import error, a model setting that is wrong), and on a cloud place each attempt
64
+ pays for a machine. The log says why; a run started again tries once more."""
65
+
66
+
67
+ class Refused(Exception):
68
+ """The host answered, and the attempt cannot go on: the same would happen again (an input that changed since the run
69
+ read it, a root that is not the user's). It fails as the node's own error, not a crash to retry."""
70
+ def __init__(self, code: str, message: str):
71
+ super().__init__(message)
72
+ self.code = code
73
+
74
+
75
+ class Deadline(Exception):
76
+ pass
77
+
78
+
79
+ class GuardRefused(Exception):
80
+ pass
81
+
82
+
83
+ class LaunchFailed(Exception):
84
+ """The cloud machine did not start (no capacity, a setup that failed or hung): worth another attempt."""
85
+
86
+
87
+ # Publish a folder in one step: rename $1 to $2 unless $2 exists, in which case $1 is dropped (the same content, by its
88
+ # hash, arrived first). rename(2) onto a folder that is not empty fails, so a published folder is never replaced or
89
+ # nested into: GNU mv -T on Linux, perl's rename on a Mac (BSD mv has no -T and would move $1 into $2).
90
+ PUBLISH = r"""publish() {
91
+ mv -T "$1" "$2" 2>/dev/null || perl -e 'rename($ARGV[0], $ARGV[1]) or exit 1' "$1" "$2" 2>/dev/null || { chmod -R u+w "$1"; rm -rf "$1"; [ -e "$2/.complete" ]; }
92
+ }"""
93
+
94
+ # The host's backstop for a node its driver lost (the laptop gone, a cancel that could not reach the host): every
95
+ # `every` seconds, while the attempt's folder exists, if no driver has written the heartbeat (alive) for `lost` seconds
96
+ # and the node is still running, stop its process group (only if that pid is still this attempt's node) and remove the
97
+ # folder: its outputs are partial. A node that finished keeps its folder for `keep` seconds, for a driver that comes back
98
+ # (a laptop that slept) to fetch: its results are whole, and running it again would cost the GPU time again.
99
+ WATCHDOG = r"""w=$1 pid=$2 lost=$3 every=$4 keep=$5
100
+ while [ -d "$w" ]; do
101
+ sleep "$every"
102
+ [ -d "$w" ] || exit 0
103
+ last=$(cat "$w/alive" 2>/dev/null)
104
+ case "$last" in ''|*[!0-9]*) continue;; esac # not a time (being written): never read as 0, which is long ago
105
+ age=$(( $(date +%s) - last ))
106
+ if ps -ww -p "$pid" -o command= 2>/dev/null | grep -qF -- "$w/request.json"; then
107
+ [ "$age" -gt "$lost" ] || continue
108
+ kill -s TERM -- "-$pid" 2>/dev/null; sleep 5; kill -s KILL -- "-$pid" 2>/dev/null
109
+ elif [ "$age" -le "$keep" ]; then
110
+ continue
111
+ fi
112
+ rm -rf "$w"
113
+ rmdir "${w%/*}" "${w%/*/*}" 2>/dev/null # the run's folders too, once empty (as fetch does)
114
+ exit 0
115
+ done"""
116
+
117
+
118
+ def heartbeat_sh(attempt: str) -> str:
119
+ """sh that writes the heartbeat the watchdog reads, in one step (written beside it, then renamed): `date > alive`
120
+ empties the file first, and a watchdog reading it then would see no time at all."""
121
+ a = shlex.quote(attempt)
122
+ return f"date +%s > {a}/alive.tmp 2>/dev/null && mv -f {a}/alive.tmp {a}/alive 2>/dev/null"
123
+
124
+
125
+ def removal_at_boot(at: int, start: str) -> str:
126
+ """A pod's start command: its removal at `at` (a Unix time), set before anything else runs on it and asked again
127
+ until RunPod does it (runpodctl, with the pod's own key, scoped to that pod), then the image's own start (sshd).
128
+ The backstop for a pod whose driver never comes back, from its first moment; perception-deadline names it (guard.sh)."""
129
+ # asked every 30 s until it happens, whatever runpodctl's exit says: the pod's removal ends this loop with it
130
+ removal = (f"until [ $(date +%s) -ge {int(at)} ]; do sleep 15; done; "
131
+ 'while :; do RUNPOD_NO_UPDATE_CHECK=1 runpodctl pod delete "$RUNPOD_POD_ID"; sleep 30; done')
132
+ return f"nohup sh -c {shlex.quote(removal)} perception-deadline > /dev/null 2>&1 & exec {start}"
133
+
134
+
135
+ def say(msg: str):
136
+ print(f"[remote] {msg}", flush=True)
137
+
138
+
139
+ class Driver:
140
+ def __init__(self, request_path: Path, spec: dict):
141
+ self.request_path, self.spec = request_path, spec
142
+ self.local = request_path.parent
143
+ self.request = json.loads(request_path.read_text())
144
+ self.pid: int | None = None
145
+ self.uploading = False # an upload to the host began: discard_incoming has something to look for
146
+ self.fetching = False # the node finished and its results are coming back: within the deadline's margin
147
+ self.unreachable_since: float | None = None
148
+ self.started = time.monotonic()
149
+ self.stop_at: float | None = None # a cloud machine's deadline, less the time to bring results back and delete it
150
+ self.extra_usd = 0.0
151
+ self.phases: dict[str, float] = {} # seconds each part of the attempt took (in the response's usage, and the log)
152
+ self.deadline_set = False # the machine's own OS shutdown is scheduled (guard.sh): it cannot outlive its deadline
153
+ self.launch_unstopped = False # a launch whose cancel on SkyPilot's server was not confirmed: it may still make a machine
154
+ # a RunPod pod: its id once created, the price RunPod gave it, and whether a create was sent without an answer
155
+ self.pod: str | None = None
156
+ self.pod_name: str | None = None
157
+ self.pod_usd: float | None = None
158
+ self.pod_unknown = False
159
+ # the account key, for this process alone: out of the environment its ssh and rsync commands inherit
160
+ self.runpod_key = os.environ.pop("RUNPOD_API_KEY", None)
161
+ self.at(spec["dest"], spec["root"])
162
+
163
+ def at(self, dest: str, root: str):
164
+ self.dest, self.root = dest, root
165
+ r = self.request
166
+ self.attempt = f"{root}/work/{r['run']}/{r['node']}/{r['attempt']}"
167
+ self.node_runs = f"{root}/work/{r['run']}/{r['node']}"
168
+ self.code = f"{root}/code/{self.spec['code']}"
169
+ self.tag = f"{r['run']}-{r['node']}-{r['attempt']}" # this attempt's own names for what it uploads
170
+
171
+ # --- commands on the host -----------------------------------------------------------------------------------
172
+
173
+ def sh(self, script: str, check: bool = True, data: bytes | None = None, timeout: float = 600) -> subprocess.CompletedProcess:
174
+ """Run a POSIX sh script on the host (through sh: the login shell may be zsh or fish). ssh's own failures (exit
175
+ 255: no connection) raise Unreachable."""
176
+ out = subprocess.run([*self.spec["ssh"], self.dest, f"sh -c {shlex.quote(script)}"], input=data, capture_output=True, timeout=timeout)
177
+ if out.returncode == 255:
178
+ raise Unreachable(out.stderr.decode(errors="replace").strip()[-300:])
179
+ if check and out.returncode in REFUSALS:
180
+ raise Refused(REFUSALS[out.returncode], out.stderr.decode(errors="replace").strip()[-500:])
181
+ if check and out.returncode:
182
+ raise RuntimeError(f"on {self.spec['host']}: {script[:120]}... exited {out.returncode}: "
183
+ f"{out.stderr.decode(errors='replace').strip()[-500:]}")
184
+ return out
185
+
186
+ def rsync(self, *args: str):
187
+ shell = " ".join(shlex.quote(a) for a in self.spec["ssh"])
188
+ # -rlt: files, folders, links as links (never followed: a link in out/ is refused at publication) and times;
189
+ # no devices, permissions or owners
190
+ out = subprocess.run(["rsync", "-rlt", "-e", shell, *args], capture_output=True, text=True, timeout=24 * 3600)
191
+ if out.returncode:
192
+ raise RuntimeError(f"rsync {' '.join(args[-2:])} failed ({out.returncode}): {out.stderr.strip()[-500:]}")
193
+
194
+ # --- steps ----------------------------------------------------------------------------------------------------
195
+
196
+ def check_root(self):
197
+ """The root must still be the user's own private folder: the markers below are trusted only because no one
198
+ else can write there (hosts.PRIVATE_ROOT)."""
199
+ from .hosts import private_root_script
200
+ self.sh(private_root_script(self.root))
201
+
202
+ def send_code(self):
203
+ """The code under code/<its hash>, published in one step (PUBLISH): runs on the host at the same time may send
204
+ the same code. What is sent is a copy taken here and checked against that hash first: a file saved while the
205
+ code is on its way must not be filed under the hash of the code before it."""
206
+ import shutil
207
+ from .backends.native import REPO
208
+ from .backends.remote import code_hash
209
+ q = shlex.quote
210
+ if self.sh(f"test -e {q(self.code)}/.complete", check=False).returncode == 0:
211
+ return
212
+ snapshot = self.local / "code"
213
+ shutil.rmtree(snapshot, ignore_errors=True)
214
+ for f in self.spec["files"]:
215
+ (snapshot / f).parent.mkdir(parents=True, exist_ok=True)
216
+ shutil.copy2(REPO / f, snapshot / f)
217
+ if code_hash(self.spec["files"], snapshot) != self.spec["code"]:
218
+ shutil.rmtree(snapshot)
219
+ raise RuntimeError("the node's code changed since this attempt started: nothing was sent (the next attempt sends the new code)")
220
+ say(f"copying the node's code to {self.spec['host']}")
221
+ incoming = f"{self.root}/code/.incoming-{self.tag}"
222
+ self.uploading = True
223
+ self.sh(f"rm -rf {q(incoming)} && mkdir -p {q(incoming)}")
224
+ listing = self.local / "code-files.txt"
225
+ listing.write_text("".join(f + "\n" for f in self.spec["files"]))
226
+ self.rsync("--files-from", str(listing), f"{snapshot}/", f"{self.dest}:{incoming}/")
227
+ self.sh(f"{PUBLISH}\ntouch {q(incoming)}/.complete && publish {q(incoming)} {q(self.code)}")
228
+ shutil.rmtree(snapshot)
229
+
230
+ def send_inputs(self) -> dict[str, dict]:
231
+ """Each input as objects/<sha256>/data on the host, published in one step (PUBLISH): nodes on the host at the
232
+ same time may send the same input. The node gets it under its own name, a link in its attempt's in/ (the same
233
+ bytes can arrive under another name: the video renamed)."""
234
+ q = shlex.quote
235
+ out = {}
236
+ for name, entry in self.request["inputs"].items():
237
+ # the content, not a link to it: a graph input is often a link (to a video on an external drive), which rsync
238
+ # would send as a link pointing nowhere there (or somewhere else). The node gets it under the name it has
239
+ # here, the link's own, as a local run gives it (decode records it): the target's name never goes there.
240
+ given = Path(entry["path"])
241
+ local = given.resolve()
242
+ folder = f"{self.root}/objects/{entry['sha256']}"
243
+ out[name] = {**entry, "path": f"{self.attempt}/in/{name}/{given.name}", "object": f"{folder}/data"}
244
+ if self.sh(f"test -e {q(folder)}/.complete", check=False).returncode == 0:
245
+ continue
246
+ size = sum(p.stat().st_size for p in local.rglob("*") if p.is_file()) if local.is_dir() else local.stat().st_size
247
+ say(f"copying {name} ({size / 1e6:.1f} MB) to {self.spec['host']}")
248
+ incoming = f"{self.root}/objects/.incoming-{self.tag}-{name}"
249
+ self.uploading = True
250
+ self.sh(f"rm -rf {q(incoming)} && mkdir -p {q(incoming)}")
251
+ self.rsync(f"{local}/" if local.is_dir() else str(local), f"{self.dest}:{incoming}/data" + ("/" if local.is_dir() else ""))
252
+ # filed under its sha256 only if that is what arrived (a file changed since the run hashed it is refused);
253
+ # a folder comes from the store, which checked it when it was published
254
+ verify = "" if local.is_dir() else (
255
+ f"got=$( (sha256sum {q(incoming)}/data 2>/dev/null || shasum -a 256 {q(incoming)}/data) | cut -d' ' -f1)\n"
256
+ f"[ \"$got\" = {q(entry['sha256'])} ] || {{ rm -rf {q(incoming)}; echo \"{name} arrived as $got, not {entry['sha256']}: "
257
+ f"it changed since the run read it\" >&2; exit 5; }}\n")
258
+ # read-only once published, as the local store's objects are: a node that wrote to its input (through its
259
+ # link) would change it for every later run on this host, under the same sha256
260
+ self.sh(f"{PUBLISH}\n{verify}touch {q(incoming)}/.complete && publish {q(incoming)} {q(folder)} && chmod -R a-w {q(folder)}")
261
+ return out
262
+
263
+ def start(self, inputs: dict[str, dict]):
264
+ q, s = shlex.quote, self.spec
265
+ request = {**self.request, "inputs": {k: {x: y for x, y in v.items() if x != "object"} for k, v in inputs.items()},
266
+ "out_dir": f"{self.attempt}/out"}
267
+ if s["runner_kind"] == "self":
268
+ node = f"{q(s['python'])} {q(s['entry'])}"
269
+ else:
270
+ node = f"{q(s['uv'])} run --project {q(self.code + '/' + s['env'])} --frozen --no-dev python {q(s['entry'])}"
271
+ req = f"{self.attempt}/request.json"
272
+ path = ":".join(s["path"])
273
+ links = "\n".join(f"mkdir -p {q(self.attempt)}/in/{q(name)} && ln -sfn {q(v['object'])} {q(v['path'])}"
274
+ for name, v in inputs.items())
275
+ lost = int(s["lost_after_s"] + min(60, s["lost_after_s"])) # after the driver has given up
276
+ every = max(1, min(30, lost // 4))
277
+ # an earlier attempt of this node that a lost connection left running would hold the GPU: stop it first
278
+ script = f"""
279
+ set -e
280
+ for f in {q(self.node_runs)}/*/pid; do
281
+ [ -f "$f" ] || continue
282
+ old=$(cat "$f")
283
+ # only if that pid is still that attempt's node (pids are reused on a shared machine)
284
+ if ps -ww -p "$old" -o command= 2>/dev/null | grep -qF -- "${{f%/pid}}/request.json"; then
285
+ kill -s TERM -- "-$old" 2>/dev/null || true
286
+ n=0; while [ $n -lt 10 ] && kill -0 "$old" 2>/dev/null; do sleep 1; n=$((n + 1)); done # gone before this one starts
287
+ kill -s KILL -- "-$old" 2>/dev/null || true
288
+ fi
289
+ done
290
+ mkdir -p {q(self.attempt)}/out
291
+ {links}
292
+ cat > {q(req)}
293
+ {heartbeat_sh(self.attempt)}
294
+ cd {q(self.code + '/' + s['node_dir'])}
295
+ # its own session, so its whole process tree can be stopped (kill -s TERM -- -PID) and a closed connection does not
296
+ # reach it: setsid on Linux, perl's on a Mac (no setsid command there). Not a group leader in the background, so
297
+ # neither forks: $! stays the node's pid.
298
+ if command -v setsid >/dev/null 2>&1; then set -- setsid; else set -- perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV or die'; fi
299
+ PATH={q(path)}${{PATH:+:$PATH}} nohup "$@" {node} --request {q(req)} > {q(self.attempt)}/log.txt 2>&1 < /dev/null &
300
+ pid=$!
301
+ echo $pid > {q(self.attempt)}/pid
302
+ # a cancel that came while this script ran (and found no pid yet) left a stop mark: stop the node now
303
+ if [ -e {q(self.attempt)}/stop ]; then kill -s TERM -- "-$pid" 2>/dev/null || true; fi
304
+ nohup "$@" sh -c {q(WATCHDOG)} watchdog {q(self.attempt)} "$pid" {lost} {every} {KEEP_S} > /dev/null 2>&1 < /dev/null &
305
+ echo $pid
306
+ """
307
+ out = self.sh(script, data=json.dumps(request, indent=1).encode())
308
+ self.pid = int(out.stdout.decode().split()[-1])
309
+ say(f"started on {s['host']} (pid {self.pid})")
310
+
311
+ def unreachable(self, e: Exception):
312
+ """Wait out a host that cannot be reached, for up to lost_after_s in all since it was last reached; past that,
313
+ give up (raise). Not past a cloud machine's deadline: that is a failure of its own (Deadline)."""
314
+ now = time.monotonic()
315
+ if self.stop_at is not None and not self.fetching and now > self.stop_at:
316
+ raise Deadline()
317
+ if self.unreachable_since is None:
318
+ self.unreachable_since = now
319
+ say(f"{self.spec['host']} cannot be reached ({e}); waiting up to {self.spec['lost_after_s']:.0f} s")
320
+ elif now - self.unreachable_since > self.spec["lost_after_s"]:
321
+ raise Unreachable(str(e))
322
+ time.sleep(POLL_S)
323
+
324
+ def patiently(self, step, *args):
325
+ """A step repeated while the host cannot be reached (up to lost_after_s, as watching waits): a short outage
326
+ must not cost an attempt, nor a finished node's results. A failed rsync is repeated only if the host is
327
+ unreachable; any other failure is the step's own."""
328
+ while True:
329
+ # an upload that ran into a cloud machine's deadline; never a fetch, which the deadline keeps a margin for
330
+ if self.stop_at is not None and not self.fetching and time.monotonic() > self.stop_at:
331
+ raise Deadline()
332
+ try:
333
+ result = step(*args)
334
+ self.unreachable_since = None
335
+ return result
336
+ except Unreachable as e:
337
+ self.unreachable(e)
338
+ except RuntimeError as e:
339
+ try:
340
+ self.sh("true")
341
+ except Unreachable:
342
+ self.unreachable(e)
343
+ continue
344
+ raise
345
+
346
+ @contextlib.contextmanager
347
+ def heartbeat(self):
348
+ """Keep writing the heartbeat while something long happens (a large out/ coming back), so the host's watchdog
349
+ does not take this attempt for one no driver is watching."""
350
+ done = threading.Event()
351
+ every = min(POLL_S, max(0.2, self.spec["lost_after_s"] / 2))
352
+
353
+ def beat():
354
+ while not done.wait(every):
355
+ try:
356
+ self.sh(heartbeat_sh(self.attempt), check=False)
357
+ except (Unreachable, subprocess.TimeoutExpired):
358
+ pass # a dropped connection: the step itself waits it out
359
+ thread = threading.Thread(target=beat, daemon=True)
360
+ thread.start()
361
+ try:
362
+ yield
363
+ finally:
364
+ done.set()
365
+ thread.join()
366
+
367
+ def watch(self):
368
+ q = shlex.quote
369
+ req, log = f"{self.attempt}/request.json", f"{self.attempt}/log.txt"
370
+ offset = 0
371
+ while True:
372
+ # the heartbeat first: the host's watchdog ends a node no driver has asked about for too long
373
+ check = (f"{heartbeat_sh(self.attempt)}; "
374
+ f"if kill -0 {self.pid} 2>/dev/null && ps -ww -p {self.pid} -o command= | grep -qF -- {q(req)}; "
375
+ f"then echo running; else echo exited; fi; tail -c +{offset + 1} {q(log)} 2>/dev/null || true")
376
+ try:
377
+ out = self.sh(check).stdout
378
+ self.unreachable_since = None
379
+ except Unreachable as e:
380
+ self.unreachable(e)
381
+ continue
382
+ state, _, text = out.partition(b"\n")
383
+ if text:
384
+ sys.stdout.buffer.write(text)
385
+ sys.stdout.flush()
386
+ offset += len(text)
387
+ if state.strip() == b"exited":
388
+ return
389
+ if self.stop_at is not None and time.monotonic() > self.stop_at:
390
+ raise Deadline()
391
+ time.sleep(POLL_S)
392
+
393
+ def fetch(self):
394
+ """out/ and response.json, into this attempt's folder. No response there: the node crashed (none is written
395
+ here either, and the coordinator sees a crash). The node has finished: a dropped connection is waited out, and
396
+ the heartbeat kept up, rather than its results lost."""
397
+ q = shlex.quote
398
+ self.fetching = True
399
+ with self.heartbeat():
400
+ # never through a link the node made: rsync would copy what `out/` points at, which publication cannot see
401
+ listing = self.patiently(self.sh, f"cd {q(self.attempt)} 2>/dev/null || exit 0; [ -d out ] && [ ! -L out ] && echo out; "
402
+ f"[ -f response.json ] && [ ! -L response.json ] && echo response.json; true").stdout.decode().split()
403
+ if "out" in listing:
404
+ self.patiently(self.rsync, f"{self.dest}:{self.attempt}/out/", f"{self.local}/out/")
405
+ if "response.json" in listing:
406
+ self.patiently(self.rsync, f"{self.dest}:{self.attempt}/response.json", f"{self.local}/response.json.part")
407
+ # the run's folders too once empty (another node of the run may still be using them)
408
+ self.patiently(self.sh, f"rm -rf {q(self.attempt)}; rmdir {q(self.node_runs)} {q(self.node_runs.rsplit('/', 1)[0])} 2>/dev/null || true")
409
+
410
+ def prepare(self):
411
+ """What `perception setup` does for a node here, done on the host before it starts: its uv environment synced from
412
+ its lock, then its prepare script (its weights) run in it with its settings. A failure is the attempt's own and
413
+ final (PrepareFailed: not retried, since it would most likely fail again, at a cloud machine's cost)."""
414
+ q, s = shlex.quote, self.spec
415
+ path = ":".join(s["path"])
416
+ node_dir = f"{self.code}/{s['node_dir']}"
417
+ if s["runner_kind"] == "self":
418
+ python = q(s["python"])
419
+ sync = ""
420
+ else:
421
+ project = q(f"{self.code}/{s['env']}")
422
+ python = f"{q(s['uv'])} run --project {project} --frozen --no-dev python"
423
+ sync = f"{q(s['uv'])} sync --project {project} --frozen --no-dev"
424
+ steps = [x for x in (sync, f"{python} {q(s['prepare'])} {q(json.dumps(self.request.get('config', {})))}" if s.get("prepare") else "") if x]
425
+ if not steps:
426
+ return
427
+ say("preparing the node there (its environment, its weights)")
428
+ # within a cloud machine's deadline: past it, the node could not run anyway
429
+ limit = 3600 if self.stop_at is None else self.stop_at - time.monotonic()
430
+ if limit <= 0:
431
+ raise Deadline()
432
+ try:
433
+ done = self.patiently(self.sh, f"cd {q(node_dir)} && export PATH={q(path)}${{PATH:+:$PATH}} && " + " && ".join(steps),
434
+ False, None, limit)
435
+ except subprocess.TimeoutExpired:
436
+ if self.stop_at is not None:
437
+ raise Deadline()
438
+ raise PrepareFailed("preparing the node took more than an hour")
439
+ sys.stdout.buffer.write(done.stdout[-4000:] + done.stderr[-4000:])
440
+ if done.returncode:
441
+ raise PrepareFailed(f"preparing the node failed (exit {done.returncode}): see the end of the log")
442
+
443
+ def stop(self):
444
+ """Stop this attempt's node on the host, if it started (by its pid file: the driver may have been canceled
445
+ between starting it and reading its pid)."""
446
+ signal.signal(signal.SIGTERM, signal.SIG_IGN) # a second cancel must not interrupt the stopping
447
+ q = shlex.quote
448
+ req, pidfile = f"{self.attempt}/request.json", f"{self.attempt}/pid"
449
+ say(f"stopping it on {self.spec['host']}")
450
+ try:
451
+ # the mark first, then the pid: a start still running on the host sees the one, or this finds the other
452
+ self.sh(f'mkdir -p {q(self.attempt)} && touch {q(self.attempt)}/stop; [ -f {q(pidfile)} ] || exit 0; p=$(cat {q(pidfile)}); '
453
+ f'if ps -ww -p "$p" -o command= | grep -qF -- {q(req)}; then kill -s TERM -- "-$p"; fi', check=False)
454
+ except (Unreachable, subprocess.TimeoutExpired) as e:
455
+ say(f"could not reach {self.spec['host']} to stop it ({e}); it ends when the next attempt of this node starts there")
456
+
457
+ @contextlib.contextmanager
458
+ def phase(self, name: str):
459
+ """Time a part of the attempt (launch, setup, code, inputs, run, fetch, teardown): where its time goes."""
460
+ start = time.monotonic()
461
+ try:
462
+ yield
463
+ finally:
464
+ self.phases[name] = round(self.phases.get(name, 0.0) + time.monotonic() - start, 1)
465
+
466
+ def publish_response(self):
467
+ """The node's response, now that it is final: with what the cloud machine cost added to what the node spent, and
468
+ where the attempt's time went (usage.phases; `startup` is the run's time outside the node's own work: its imports,
469
+ and the driver's ssh round trips and polling)."""
470
+ if self.phases:
471
+ say("time: " + ", ".join(f"{k} {v:.0f} s" for k, v in self.phases.items()))
472
+ part = self.local / "response.json.part"
473
+ if not part.exists():
474
+ return
475
+ response = json.loads(part.read_text())
476
+ usage = response.setdefault("usage", {})
477
+ if self.extra_usd:
478
+ usage["usd"] = round(float(usage.get("usd", 0.0)) + self.extra_usd, 4)
479
+ usage["machine_usd"] = self.extra_usd
480
+ phases = dict(self.phases)
481
+ if "run" in phases and isinstance(usage.get("seconds"), (int, float)):
482
+ phases["startup"] = round(max(0.0, phases["run"] - usage["seconds"]), 1)
483
+ usage["phases"] = phases
484
+ part.write_text(json.dumps(response, indent=1))
485
+ part.replace(self.local / "response.json")
486
+
487
+ # --- a cloud machine ------------------------------------------------------------------------------------------
488
+
489
+ def cloud_env(self, c: dict) -> dict:
490
+ return {**os.environ, "SKYPILOT_PROJECT_CONFIG": c["project_config"]}
491
+
492
+ def provision(self, c: dict):
493
+ """Start the machine. Its own deadline is set from the first moment, in case this laptop never comes back: the
494
+ task's setup schedules the OS shutdown first (DEADLINE_EPOCH), and while the launch runs a watcher makes an OS
495
+ shutdown terminate the instance (EC2 would only stop it, its disk billing). The launch has a time limit
496
+ (launch_s). SkyPilot runs a launch on its API server, where it goes on if its client is stopped: so it is
497
+ submitted (--async), followed by its id, and on a timeout or a cancel canceled there before the machine is
498
+ deleted."""
499
+ with self.phase("launch"):
500
+ if c.get("provider") == "runpod":
501
+ self.runpod_launch(c)
502
+ else:
503
+ self.launch(c)
504
+ with self.phase("setup"):
505
+ self.set_up(c)
506
+
507
+ def launch(self, c: dict):
508
+ from .hosts import HostError, probe
509
+ say(f"starting a cloud machine, {c['cluster']} (at most {c['deadline_s'] / 60:.0f} min at ${c['hourly_usd']}/h)")
510
+ # a moment, not a delay: SkyPilot runs that setup detached, perhaps after guard.sh, and must not move the deadline on
511
+ # (a minute early: the machine's clock may be up to a minute behind this one's, which is checked)
512
+ deadline_at = int(time.time() + c["deadline_s"] - (time.monotonic() - self.started)) - 60
513
+ request = None
514
+ try:
515
+ submitted = subprocess.run([c["sky"], "launch", "--async", "-c", c["cluster"], "-y", "--down", "-i", "15",
516
+ "--env", f"DEADLINE_EPOCH={deadline_at}", c["task"]], env=self.cloud_env(c),
517
+ capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=600)
518
+ sys.stdout.write(submitted.stdout + submitted.stderr)
519
+ sys.stdout.flush()
520
+ found = re.search(r"request: ([0-9a-f]{8}-[0-9a-f-]{27,})", re.sub(r"\x1b\[[0-9;]*m", "", submitted.stdout))
521
+ if submitted.returncode or not found:
522
+ raise LaunchFailed(f"sky launch was not submitted, or did not say its request (exit {submitted.returncode})")
523
+ request = found.group(1)
524
+ except FileNotFoundError:
525
+ raise Refused("input", f"SkyPilot is not installed here ({c['sky']}): perception cloud on checks for it")
526
+ finally:
527
+ if request is None: # interrupted, timed out or not understood: it may still have been submitted
528
+ self.cancel_launches(c)
529
+ logs = subprocess.Popen([c["sky"], "api", "logs", request], env=self.cloud_env(c), stdout=sys.stdout,
530
+ stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
531
+ terminating, finished = False, False
532
+ try:
533
+ while True:
534
+ state = self.request_status(c, request)
535
+ if state == "SUCCEEDED":
536
+ finished = True
537
+ break
538
+ if state in ("FAILED", "CANCELLED"):
539
+ finished = True
540
+ raise LaunchFailed(f"sky launch {state.lower()}")
541
+ if not terminating:
542
+ terminating = self.terminate_on_shutdown(c, quiet=True)
543
+ if time.monotonic() - self.started > c["launch_s"]:
544
+ raise LaunchFailed(f"the machine was not ready after {c['launch_s'] / 60:.0f} min (sky launch canceled)")
545
+ time.sleep(POLL_S)
546
+ finally:
547
+ if not finished: # a timeout or a cancel: stop the launch on the API server, or sky down could miss it
548
+ self.sky_cancel(c, [request])
549
+ logs.terminate()
550
+ logs.wait()
551
+ # again now that it is up: the launch may have replaced the instance it first created (another zone). A machine
552
+ # that would only stop at its deadline (its disk billing on) is not used.
553
+ if not self.terminate_on_shutdown(c):
554
+ raise Refused("internal", f"{c['cluster']} could not be set to terminate on shutdown (aws ec2 modify-instance-attribute): it is not used")
555
+ try:
556
+ caps, root = probe(c["cluster"])
557
+ except HostError as e:
558
+ raise LaunchFailed(f"{c['cluster']} started but could not be probed: {e}")
559
+ self.at(c["cluster"], root)
560
+ self.spec.update(uv=caps.tools.get("uv"), path=sorted({str(Path(t).parent) for t in caps.tools.values() if t}))
561
+ if "cuda" not in caps.accelerators:
562
+ raise Refused("resources", f"{c['cluster']} has no NVIDIA GPU that nvidia-smi can see")
563
+ # setup schedules the shutdown at the deadline as a moment by this laptop's clock: the machine's must agree
564
+ self.check_clock(c["cluster"])
565
+ # its OS shuts it down (and so terminates it) at the deadline, counted from the start of the launch
566
+ minutes = max(1, math.floor((c["deadline_s"] - (time.monotonic() - self.started)) / 60))
567
+ guard = Path(c["guard"]).read_bytes()
568
+ result = self.sh(f"bash -s {minutes}", check=False, data=guard)
569
+ sys.stdout.buffer.write(result.stdout + result.stderr)
570
+ if result.returncode:
571
+ raise GuardRefused(f"guard.sh refused {c['cluster']} (exit {result.returncode}): nothing was sent; see the log")
572
+ self.deadline_set = True
573
+
574
+ def check_clock(self, machine: str):
575
+ """The machine's own deadline is a moment by this laptop's clock (a minute early): its clock must agree, to a minute."""
576
+ skew = int(self.sh("date +%s").stdout.decode().strip()) - time.time()
577
+ if abs(skew) > 60:
578
+ raise Refused("internal", f"{machine}'s clock and this machine's differ by {skew:.0f} s: set this machine's "
579
+ "clock (its deadline is a moment by it)")
580
+
581
+ def set_up(self, c: dict):
582
+ # what the node needs, installed now and waited for (SkyPilot's own setup runs detached: node-setup.sh)
583
+ machine = c.get("cluster") or f"pod {self.pod}"
584
+ say("installing ffmpeg, rsync and uv on the machine")
585
+ try:
586
+ done = self.sh(f"bash -s", check=False, data=Path(c["setup"]).read_bytes())
587
+ except subprocess.TimeoutExpired:
588
+ raise LaunchFailed(f"setting up {machine} took more than 10 min")
589
+ sys.stdout.buffer.write(done.stderr)
590
+ if done.returncode:
591
+ raise LaunchFailed(f"setting up {machine} failed (exit {done.returncode}): see the log")
592
+ uv = done.stdout.decode().strip().splitlines()[-1] if done.stdout.strip() else ""
593
+ self.spec["uv"] = uv or None
594
+ if not uv:
595
+ raise LaunchFailed(f"setting up {machine} did not say where uv is")
596
+ # 5 minutes to bring results back and delete it, and 3 more: setup's shutdown may come that much early (a minute
597
+ # set early, a minute rounding down, a minute of clock skew)
598
+ self.stop_at = self.started + c["deadline_s"] - min(480, c["deadline_s"] / 4)
599
+
600
+ def cancel_launches(self, c: dict):
601
+ """Cancel, on SkyPilot's API server, any launch of this attempt's cluster still pending or running: found by its
602
+ cluster's name, which only this attempt uses (a launch whose request id was never read)."""
603
+ try:
604
+ out = subprocess.run([c["sky"], "api", "status", "-v", "-o", "json"], env=self.cloud_env(c), capture_output=True,
605
+ text=True, stdin=subprocess.DEVNULL, start_new_session=True, timeout=120)
606
+ except subprocess.TimeoutExpired:
607
+ self.launch_unstopped = True # not known to be stopped: teardown treats it as able to make a machine still
608
+ say(f"WARNING: SkyPilot did not list its requests in time to cancel a launch of {c['cluster']}; sky down follows")
609
+ return
610
+ try:
611
+ ids = [r["request_id"] for r in json.loads(out.stdout[out.stdout.index("["):])
612
+ if r.get("name") == "sky.launch" and r.get("cluster_name") == c["cluster"] and r.get("status") in ("PENDING", "RUNNING")]
613
+ except (ValueError, KeyError, TypeError):
614
+ self.launch_unstopped = True
615
+ say(f"WARNING: could not list SkyPilot's requests to cancel a launch of {c['cluster']}; sky down follows")
616
+ return
617
+ if ids:
618
+ self.sky_cancel(c, ids)
619
+
620
+ def sky_cancel(self, c: dict, requests: list[str]):
621
+ """Cancel launch requests on SkyPilot's API server, within a time limit: a server that hangs must not keep the
622
+ machine from being deleted (teardown follows whatever happens here)."""
623
+ try:
624
+ code = subprocess.run([c["sky"], "api", "cancel", "-y", *requests], env=self.cloud_env(c), stdout=sys.stdout,
625
+ stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True,
626
+ timeout=c.get("aws_s", 120)).returncode
627
+ except (subprocess.TimeoutExpired, OSError) as e:
628
+ code = str(e)
629
+ if code != 0:
630
+ self.launch_unstopped = True
631
+ say(f"WARNING: could not cancel the launch on SkyPilot's server ({code}); deleting the machine follows")
632
+
633
+ def request_status(self, c: dict, request: str) -> str:
634
+ """A request's status on SkyPilot's API server (PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED), or "" when it
635
+ could not be read this time."""
636
+ try:
637
+ out = subprocess.run([c["sky"], "api", "status", "-a", request, "-o", "json"], env=self.cloud_env(c),
638
+ capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=120)
639
+ except subprocess.TimeoutExpired:
640
+ return "" # not read this time: the launch's own time limit still applies
641
+ try:
642
+ text = out.stdout[out.stdout.index("["):]
643
+ return next((r["status"] for r in json.loads(text) if r["request_id"] == request), "")
644
+ except (ValueError, KeyError, TypeError):
645
+ return ""
646
+
647
+ def terminate_on_shutdown(self, c: dict, quiet: bool = False) -> bool:
648
+ """EC2 only stops an on-demand machine on an OS shutdown (its disk keeps billing): make it terminate, so its
649
+ deadline deletes it (as launch.sh does). Whether its instance was found and changed; `quiet` while the launch is
650
+ still creating it."""
651
+ aws = [c["aws"], "--region", c["region"], "--output", "text"]
652
+ ids = subprocess.run([*aws, "ec2", "describe-instances", "--query", "Reservations[].Instances[].InstanceId",
653
+ "--filters", f"Name=tag:skypilot-cluster-name,Values={c['cluster']},{c['cluster']}-*",
654
+ "Name=instance-state-name,Values=pending,running"], capture_output=True, text=True)
655
+ found = ids.stdout.split() if ids.returncode == 0 else []
656
+ done_all = bool(found)
657
+ for instance in found:
658
+ done = subprocess.run([*aws, "ec2", "modify-instance-attribute", "--instance-id", instance,
659
+ "--instance-initiated-shutdown-behavior", "terminate"], capture_output=True, text=True)
660
+ done_all &= done.returncode == 0
661
+ say(f"{instance} terminates on OS shutdown" if done.returncode == 0 else
662
+ f"WARNING: could not make {instance} terminate on shutdown ({done.stderr.strip()[-200:]}); its deadline only stops it")
663
+ if not found and not quiet:
664
+ say(f"WARNING: no instance found for {c['cluster']} ({ids.stderr.strip()[-200:]}); its deadline only stops it")
665
+ return done_all
666
+
667
+ def teardown(self, c: dict):
668
+ signal.signal(signal.SIGTERM, signal.SIG_IGN) # deleting the machine must not be interrupted
669
+ say(f"deleting {c['cluster']}")
670
+ try: # a limit too: a SkyPilot server that hangs must not hold the run
671
+ code = subprocess.run([c["sky"], "down", "-y", c["cluster"]], env=self.cloud_env(c), stdout=sys.stdout,
672
+ stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True,
673
+ timeout=c.get("down_s", 900)).returncode # its own session: a cancel must not stop it
674
+ except subprocess.TimeoutExpired:
675
+ code = "timed out"
676
+ if code == 0 and self.launch_unstopped:
677
+ # sky down says "not found" (exit 0) for a cluster a launch has not made yet: that proves nothing either
678
+ code = "the launch was not stopped"
679
+ if code != 0: # SkyPilot could not delete it: end the instance through EC2 directly
680
+ say(f"sky down: {code}; terminating {c['cluster']}'s instances through EC2")
681
+ # no instance found proves nothing while a launch that was not stopped may still make one
682
+ code = 0 if self.terminate_instances(c) and not self.launch_unstopped else code
683
+ seconds = time.monotonic() - self.started
684
+ if code == 0: # its time; never more than its deadline once its OS shutdown was set (it cannot outlive it then)
685
+ self.extra_usd = round(c["hourly_usd"] * (min(seconds, c["deadline_s"]) if self.deadline_set else seconds) / 3600, 4)
686
+ elif self.deadline_set: # it lives until its deadline at most (guard.sh), and is charged that
687
+ self.extra_usd = round(c["hourly_usd"] * c["deadline_s"] / 3600, 4)
688
+ say(f"WARNING: could not delete {c['cluster']}: run `sky down {c['cluster']}`; otherwise it shuts down at its deadline")
689
+ else: # no deadline on it either: nothing ends it but someone; no other attempt starts another one
690
+ self.extra_usd = round(c["hourly_usd"] * c["deadline_s"] / 3600, 4)
691
+ message = (f"{c['cluster']} could not be deleted and has no shutdown deadline (or its launch could not be stopped): "
692
+ "it may still be running, or still be created. "
693
+ f"Run `sky down {c['cluster']}`, or terminate its instance in the AWS console ({c['region']})")
694
+ say(f"WARNING: {message}")
695
+ self.fail("internal", message)
696
+ say(f"the machine cost about ${self.extra_usd:.2f}" + ("" if code == 0 else " (at least)"))
697
+
698
+ def terminate_instances(self, c: dict) -> bool:
699
+ """Terminate every instance of this attempt's cluster through EC2 (when SkyPilot could not): whether none is left.
700
+ An AWS call that fails or does not answer in time counts as not terminated (teardown then says so)."""
701
+ aws = [c["aws"], "--region", c["region"], "--output", "text"]
702
+ limit = c.get("aws_s", 120)
703
+ try:
704
+ ids = subprocess.run([*aws, "ec2", "describe-instances", "--query", "Reservations[].Instances[].InstanceId",
705
+ "--filters", f"Name=tag:skypilot-cluster-name,Values={c['cluster']},{c['cluster']}-*",
706
+ "Name=instance-state-name,Values=pending,running,stopping,stopped"],
707
+ capture_output=True, text=True, start_new_session=True, timeout=limit)
708
+ if ids.returncode:
709
+ say(f"could not list {c['cluster']}'s instances: {ids.stderr.strip()[-200:]}")
710
+ return False
711
+ found = ids.stdout.split()
712
+ if not found:
713
+ return True
714
+ done = subprocess.run([*aws, "ec2", "terminate-instances", "--instance-ids", *found], capture_output=True,
715
+ text=True, start_new_session=True, timeout=limit)
716
+ except (subprocess.TimeoutExpired, OSError) as e:
717
+ say(f"could not reach EC2 to terminate {c['cluster']}'s instances ({e})")
718
+ return False
719
+ if done.returncode:
720
+ say(f"could not terminate {', '.join(found)}: {done.stderr.strip()[-200:]}")
721
+ return False
722
+ say(f"terminated {', '.join(found)}")
723
+ return True
724
+
725
+ # --- a RunPod pod ---------------------------------------------------------------------------------------------
726
+
727
+ def runpod_api(self, c: dict):
728
+ from .runpod import RunPod
729
+ return RunPod(self.runpod_key or "", c["api"], timeout=c.get("api_s", 60))
730
+
731
+ def runpod_launch(self, c: dict):
732
+ """Create the pod (any GPU of the list, in any of the US, Canadian and European datacenters: whichever RunPod
733
+ has free), wait for its SSH port, probe it like a host and run guard.sh there before anything is sent. The pod's name carries its
734
+ deadline, so a later launch can delete it if this driver never does (runpod_sweep)."""
735
+ from .hosts import HostError, probe, ssh_command
736
+ from .runpod import RunPodError
737
+ if not self.runpod_key:
738
+ raise Refused("input", "no RunPod API key (RUNPOD_API_KEY in ~/.config/hyperstudy/runpod.env): perception cloud on runpod checks for it")
739
+ api = self.runpod_api(c)
740
+ self.runpod_sweep(api)
741
+ deadline_at = int(time.time() + c["deadline_s"] - (time.monotonic() - self.started))
742
+ self.pod_name = f"{c['name']}-d{deadline_at}"
743
+ say(f"starting a RunPod pod, {self.pod_name} (at most {c['deadline_s'] / 60:.0f} min, at most ${c['hourly_usd']}/h)")
744
+ keys = self.local / "ssh" # a key made for this pod alone: the user's own keys are never offered to it
745
+ keys.mkdir(mode=0o700, exist_ok=True)
746
+ key = keys / "id_ed25519"
747
+ if not key.exists():
748
+ subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "perception", "-f", str(key)],
749
+ check=True, capture_output=True, stdin=subprocess.DEVNULL)
750
+ body = {"name": self.pod_name, "imageName": c["image"], "gpuTypeIds": c["gpus"], "gpuTypePriority": "custom",
751
+ "gpuCount": 1, "cloudType": "SECURE", "dataCenterIds": c["datacenters"], "dataCenterPriority": "availability",
752
+ # its own disk only: no volume, so nothing (the inputs above all) outlives the pod
753
+ "containerDiskInGb": c["disk_gb"], "volumeInGb": 0, "ports": ["22/tcp"],
754
+ "env": {"PUBLIC_KEY": (keys / "id_ed25519.pub").read_text().strip()},
755
+ # no allowedCudaVersions: its values stop at 13.0 and match a host's exactly, so it excludes the hosts that
756
+ # report 13.2 (driver 595; checked 2026-10-25). guard.sh checks the driver instead
757
+ "dockerStartCmd": ["bash", "-c", removal_at_boot(deadline_at - 60, c["start"])]}
758
+ self.pod_unknown = True # until RunPod answers, a pod may exist that only its name can find
759
+ try:
760
+ pod = api.create_pod(body)
761
+ except RunPodError as e:
762
+ # only a 4xx, a create never sent (this laptop offline) and RunPod's answer when none of the GPUs is free (a
763
+ # 500) prove no pod was made; any other failure may come after it was made (a gateway's 502): teardown looks
764
+ # for it by its name
765
+ if e.nothing_done or "no instances currently available" in str(e):
766
+ self.pod_unknown = False
767
+ if e.nothing_done and e.sent: # RunPod refused it: the same would happen again
768
+ raise Refused("internal", f"RunPod refused to create the pod: {e}")
769
+ raise LaunchFailed(f"RunPod did not start a pod: {e}")
770
+ self.pod, self.pod_unknown = pod["id"], False
771
+ self.pod_usd = float(pod.get("costPerHr") or c["hourly_usd"])
772
+ say(f"pod {self.pod}: ${self.pod_usd}/h")
773
+ if self.pod_usd > c["hourly_usd"]: # its price rose past the list's: the run's reservation would not cover it
774
+ raise Refused("internal", f"RunPod's price for pod {self.pod} is ${self.pod_usd}/h, above the ${c['hourly_usd']}/h "
775
+ "reserved for it: perception's RunPod price list needs updating")
776
+ while True:
777
+ try:
778
+ p = api.pod(self.pod)
779
+ except RunPodError as e:
780
+ if e.status:
781
+ raise LaunchFailed(f"RunPod could not say how pod {self.pod} is: {e}")
782
+ p = {} # no answer this time: the launch's time limit still applies
783
+ if p is None:
784
+ raise LaunchFailed(f"pod {self.pod} went away while it was starting")
785
+ if p.get("publicIp") and (p.get("portMappings") or {}).get("22"):
786
+ break
787
+ if time.monotonic() - self.started > c["launch_s"]:
788
+ raise LaunchFailed(f"pod {self.pod} had no SSH address after {c['launch_s'] / 60:.0f} min")
789
+ time.sleep(POLL_S)
790
+ # its host key is new: accepted the first time, in a file of this attempt's own, and held to after that
791
+ # (quoted: ssh reads an unquoted path with a space as two files). None of the user's ssh config applies (-F none:
792
+ # its IdentityFiles would be offered too), and nothing of this machine's is forwarded to it (an agent, ports, X11)
793
+ self.spec["ssh"] = [*ssh_command(), "-F", "none", "-i", str(key), "-p", str(int(p["portMappings"]["22"])), "-o", "IdentitiesOnly=yes",
794
+ "-o", f'UserKnownHostsFile="{keys / "known_hosts"}"', "-o", "StrictHostKeyChecking=accept-new",
795
+ "-o", "ForwardAgent=no", "-o", "ClearAllForwardings=yes", "-o", "ForwardX11=no"]
796
+ dest = f"root@{p['publicIp']}"
797
+ self.dest = dest
798
+ while True: # its sshd starts a moment after the port is mapped
799
+ try:
800
+ if self.sh("true", check=False, timeout=60).returncode == 0:
801
+ break
802
+ except (Unreachable, subprocess.TimeoutExpired):
803
+ pass
804
+ if time.monotonic() - self.started > c["launch_s"]:
805
+ raise LaunchFailed(f"pod {self.pod}'s SSH did not answer within {c['launch_s'] / 60:.0f} min")
806
+ time.sleep(POLL_S)
807
+ try:
808
+ caps, root = probe(dest, ssh=self.spec["ssh"])
809
+ except HostError as e:
810
+ raise LaunchFailed(f"pod {self.pod} started but could not be probed: {e}")
811
+ self.at(dest, root)
812
+ self.spec.update(uv=caps.tools.get("uv"), path=sorted({str(Path(t).parent) for t in caps.tools.values() if t}))
813
+ if "cuda" not in caps.accelerators:
814
+ raise Refused("resources", f"pod {self.pod} has no NVIDIA GPU that nvidia-smi can see")
815
+ self.check_clock(f"pod {self.pod}") # it removes itself at its deadline, a moment by this laptop's clock
816
+ # its removal at the deadline is running (its start command), and it holds no key to the account
817
+ result = self.sh(f"bash -s {int(c['min_driver'])}", check=False, data=Path(c["guard"]).read_bytes())
818
+ sys.stdout.buffer.write(result.stdout + result.stderr)
819
+ if result.returncode == 75: # not this pod (an old driver), or its checks could not be made: another may do
820
+ why = (result.stderr.decode(errors="replace").strip().splitlines() or ["see the log"])[-1]
821
+ raise LaunchFailed(f"pod {self.pod} was not used: {why.removeprefix('guard: ')}")
822
+ if result.returncode:
823
+ raise GuardRefused(f"guard.sh refused pod {self.pod} (exit {result.returncode}): nothing was sent; see the log")
824
+ self.deadline_set = True
825
+
826
+ def runpod_sweep(self, api):
827
+ """Delete any perception pod past its deadline (by its name): one a driver never came back to (a laptop that died)
828
+ whose own removal at the deadline (guard.sh) did not happen either. A pod still within its deadline may be another
829
+ run's, and is left alone."""
830
+ from .runpod import POD_NAME, RunPodError
831
+ try:
832
+ pods = api.pods()
833
+ except RunPodError as e:
834
+ say(f"WARNING: could not list RunPod pods to delete any left past their deadline ({e})")
835
+ return
836
+ for p in pods:
837
+ m = POD_NAME.match(p.get("name") or "")
838
+ if m and int(m.group(1)) + SWEEP_GRACE_S < time.time():
839
+ say(f"deleting {p['name']}: a perception pod past its deadline that was never deleted")
840
+ try:
841
+ api.delete_pod(p["id"])
842
+ except RunPodError as e:
843
+ say(f"WARNING: could not delete {p['name']} ({e}): delete it at https://console.runpod.io/pods")
844
+
845
+ def runpod_teardown(self, c: dict):
846
+ """Delete the pod through the API and wait until RunPod no longer has it (404), within down_s; charged at its
847
+ price for the time it existed. A create with no clear answer may have made one a moment later: looked for by its
848
+ name for up to unknown_s, and taken as never made (charged nothing) if it does not appear."""
849
+ from .runpod import RunPodError
850
+ signal.signal(signal.SIGTERM, signal.SIG_IGN) # deleting the pod must not be interrupted
851
+ if self.pod is None and not self.pod_unknown:
852
+ return # none was made (RunPod refused, or no key): nothing to delete or charge
853
+ api = self.runpod_api(c)
854
+ say(f"deleting pod {self.pod or self.pod_name}")
855
+ start = time.monotonic()
856
+ end = start + c["down_s"]
857
+ gone, problem = False, ""
858
+ while not gone and time.monotonic() < end:
859
+ try: # deleting is idempotent: asked again until RunPod no longer has the pod or the time is up
860
+ if self.pod is None:
861
+ found = [p["id"] for p in api.pods() if p.get("name") == self.pod_name]
862
+ if not found:
863
+ if time.monotonic() - start < min(c.get("unknown_s", 180), c["down_s"]):
864
+ time.sleep(2)
865
+ continue
866
+ say(f"no pod named {self.pod_name} appeared in {c.get('unknown_s', 180):.0f} s: none was made. If one "
867
+ "appears at https://console.runpod.io/pods, delete it (the next RunPod run does, after its deadline)")
868
+ self.pod_unknown = False
869
+ return
870
+ self.pod = found[0] # names are the attempt's own: one at most
871
+ api.delete_pod(self.pod)
872
+ while time.monotonic() < end and api.pod(self.pod) is not None:
873
+ time.sleep(2)
874
+ gone = api.pod(self.pod) is None
875
+ except RunPodError as e:
876
+ problem = str(e)
877
+ say(f"RunPod: {e}")
878
+ time.sleep(min(5.0, max(0.0, end - time.monotonic())))
879
+ price = self.pod_usd or c["hourly_usd"]
880
+ seconds = time.monotonic() - self.started
881
+ if gone: # its time; never more than its deadline once its own removal was set (it cannot outlive it then)
882
+ self.extra_usd = round(price * (min(seconds, c["deadline_s"]) if self.deadline_set else seconds) / 3600, 4)
883
+ elif self.deadline_set: # it removes itself at its deadline at most (guard.sh), and is charged that
884
+ self.extra_usd = round(price * c["deadline_s"] / 3600, 4)
885
+ say(f"WARNING: could not delete pod {self.pod} ({problem or 'still there'}): it removes itself at its deadline; "
886
+ "or delete it at https://console.runpod.io/pods")
887
+ else: # nothing ends it but someone: no other attempt starts another one
888
+ self.extra_usd = round(price * c["deadline_s"] / 3600, 4)
889
+ message = (f"pod {self.pod or self.pod_name} could not be deleted ({problem or 'still there'}), and its removal at "
890
+ "its deadline was not confirmed: it may still be running. Delete it at https://console.runpod.io/pods")
891
+ say(f"WARNING: {message}")
892
+ self.fail("internal", message)
893
+ say(f"the pod cost about ${self.extra_usd:.2f}" + ("" if gone else " (at least)"))
894
+
895
+ def run(self) -> int:
896
+ cloud = self.spec.get("cloud")
897
+ try:
898
+ if cloud:
899
+ self.provision(cloud)
900
+ # each repeated while the host cannot be reached: they can be (uploads go under the attempt's own names and
901
+ # are published in one step; starting stops this attempt's node if a lost answer hid that it had started)
902
+ with self.phase("code"):
903
+ self.patiently(self.check_root)
904
+ self.patiently(self.send_code)
905
+ with self.phase("inputs"):
906
+ inputs = self.patiently(self.send_inputs)
907
+ with self.phase("prepare"): # its environment synced and its weights fetched, before it starts (setup.py)
908
+ self.prepare()
909
+ with self.phase("run"): # from starting the node to its end
910
+ self.patiently(self.start, inputs)
911
+ self.watch()
912
+ with self.phase("fetch"):
913
+ self.fetch()
914
+ return 0
915
+ except Canceled:
916
+ self.stop()
917
+ return 143
918
+ except LaunchFailed as e:
919
+ say(str(e))
920
+ # retried within the policy, charged what the machine cost until it was deleted (not the whole deadline)
921
+ self.fail("transient", str(e))
922
+ return 1
923
+ except GuardRefused as e:
924
+ say(str(e))
925
+ # the machine is not set up as this repository requires (its role, credentials on it): not worth retrying
926
+ self.fail("privacy", str(e))
927
+ return 1
928
+ except Deadline:
929
+ minutes = cloud["deadline_s"] / 60
930
+ say(f"stopping it: the machine's deadline ({minutes:.0f} min) is near")
931
+ self.stop()
932
+ # a failure of its own, not a crash: another attempt would only reach the same deadline, at the same cost
933
+ if not (self.local / "response.json.part").exists(): # never over results already brought back
934
+ self.fail("resources", f"stopped at the cloud machine's deadline ({minutes:.0f} min); a longer video needs a longer deadline")
935
+ return 124
936
+ except Unreachable as e:
937
+ say(f"{self.spec['host']} could not be reached for {self.spec['lost_after_s']:.0f} s ({e}): giving up on this attempt")
938
+ return 75
939
+ except Refused as e:
940
+ say(str(e))
941
+ self.fail(e.code, str(e))
942
+ return 1
943
+ except PrepareFailed as e:
944
+ say(str(e))
945
+ self.fail("internal", str(e)) # not retried (see PrepareFailed); charged what it took
946
+ return 1
947
+ finally:
948
+ signal.signal(signal.SIGTERM, signal.SIG_IGN) # a cancel now must not skip deleting the machine or charging it
949
+ self.discard_incoming()
950
+ if cloud:
951
+ with self.phase("teardown"):
952
+ if cloud.get("provider") == "runpod":
953
+ self.runpod_teardown(cloud)
954
+ else:
955
+ self.teardown(cloud)
956
+ self.publish_response()
957
+
958
+ def discard_incoming(self):
959
+ """This attempt's own uploads that never finished (a cancel, a failure): a partial copy of an input must not stay
960
+ on the host. One that finished was renamed into place, so nothing published is touched."""
961
+ if not self.uploading:
962
+ return # nothing was sent (a machine the guard refused gets nothing, not even this)
963
+ q = shlex.quote
964
+ try:
965
+ self.sh(f"chmod -R u+w {q(self.root)}/objects/.incoming-{q(self.tag)}-* 2>/dev/null; "
966
+ f"rm -rf {q(self.root)}/objects/.incoming-{q(self.tag)}-* {q(self.root)}/code/.incoming-{q(self.tag)}", check=False)
967
+ except (Unreachable, subprocess.TimeoutExpired) as e:
968
+ say(f"could not remove this attempt's unfinished uploads on {self.spec['host']} ({e}): perception hosts clean removes them")
969
+
970
+ def fail(self, code: str, message: str):
971
+ """A response saying the attempt failed, as a node writes one: the coordinator does not retry it (published as
972
+ the run ends, with what a cloud machine cost)."""
973
+ (self.local / "response.json.part").write_text(json.dumps({"ok": False, "usage": {}, "error": {"code": code, "message": message}}))
974
+
975
+
976
+ def main(argv: list[str] | None = None) -> int:
977
+ ap = argparse.ArgumentParser(prog="perception.remote_driver")
978
+ ap.add_argument("--request", required=True)
979
+ args = ap.parse_args(argv)
980
+ request = Path(args.request)
981
+
982
+ def on_term(signum, frame):
983
+ raise Canceled()
984
+ signal.signal(signal.SIGTERM, on_term)
985
+ driver = Driver(request, json.loads((request.parent / "remote.json").read_text()))
986
+ return driver.run()
987
+
988
+
989
+ if __name__ == "__main__":
990
+ sys.exit(main())