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
perception/runpod.py ADDED
@@ -0,0 +1,100 @@
1
+ """RunPod's REST API (https://rest.runpod.io/v1), as perception uses it: create a pod, read it, delete it, list pods.
2
+
3
+ The account key comes from the user's own keys (RUNPOD_API_KEY in ~/.config/hyperstudy/*.env, or the environment) and is
4
+ used only on this machine: never written to a file, never sent to a pod. RunPod's own CLI and SkyPilot keep it in
5
+ ~/.runpod/config.toml, which SkyPilot uploads to every machine it starts (AWS ones too), so that file is not used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import http.client
11
+ import json
12
+ import os
13
+ import re
14
+ import socket
15
+ import urllib.error
16
+ import urllib.request
17
+
18
+ API = "https://rest.runpod.io/v1"
19
+ # Cloudflare in front of the API refuses Python's default User-Agent (error 1010)
20
+ USER_AGENT = "hyperstudy-perception"
21
+ # a pod perception started: perception-<run>-<node>-<hash>-<attempt>-d<the deadline, as a Unix time>
22
+ POD_NAME = re.compile(r"^perception-[a-z0-9-]+-d(\d{9,11})$")
23
+
24
+
25
+ class RunPodError(Exception):
26
+ def __init__(self, status: int, message: str, sent: bool = True):
27
+ """`status`: RunPod's HTTP status, or 0 for no whole answer. `sent`: whether the request may have reached RunPod
28
+ (not when its address could not be found or its connection was refused)."""
29
+ super().__init__(f"RunPod answered {status}: {message}" if status else f"RunPod could not be reached: {message}")
30
+ self.status, self.sent = status, sent
31
+
32
+ @property
33
+ def nothing_done(self) -> bool:
34
+ """Whether the request is known to have done nothing: never sent, or a 4xx (not 429, a rate limit in front of it).
35
+ A 5xx, from RunPod or a gateway before it, may come after the work was done; no answer proves nothing either."""
36
+ return not self.sent or (400 <= self.status < 500 and self.status != 429)
37
+
38
+
39
+ class NoRedirects(urllib.request.HTTPRedirectHandler):
40
+ """urllib would send the key on to wherever a redirect points (another host too): the API never redirects."""
41
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
42
+ return None # the 3xx is then raised as an HTTPError
43
+
44
+
45
+ OPENER = urllib.request.build_opener(NoRedirects)
46
+
47
+
48
+ def api_base() -> str:
49
+ return os.environ.get("PERCEPTION_RUNPOD_API", API) # the tests' stand-in server
50
+
51
+
52
+ class RunPod:
53
+ def __init__(self, key: str, base: str | None = None, timeout: float = 60):
54
+ self.key, self.base, self.timeout = key, (base or api_base()).rstrip("/"), timeout
55
+
56
+ def call(self, method: str, path: str, body: dict | None = None):
57
+ req = urllib.request.Request(self.base + path, method=method, data=json.dumps(body).encode() if body is not None else None,
58
+ headers={"Authorization": f"Bearer {self.key}", "Content-Type": "application/json",
59
+ "User-Agent": USER_AGENT})
60
+ try:
61
+ with OPENER.open(req, timeout=self.timeout) as r:
62
+ text = r.read().decode()
63
+ except urllib.error.HTTPError as e:
64
+ try: # its body may be cut short too: the status is what counts
65
+ detail = e.read().decode(errors="replace").strip()[:1000] # a schema refusal names the field late
66
+ except (OSError, http.client.HTTPException) as cut:
67
+ detail = f"(its message cut short: {type(cut).__name__})"
68
+ raise RunPodError(e.code, detail) from None
69
+ # no whole answer (a timeout, a connection cut, an answer cut short: http.client's own errors are not OSErrors):
70
+ # the request may or may not have been done, unless it never left (no address, a refused connection)
71
+ except (urllib.error.URLError, OSError, http.client.HTTPException, UnicodeDecodeError) as e:
72
+ reason = getattr(e, "reason", e)
73
+ raise RunPodError(0, f"{type(e).__name__}: {reason}",
74
+ sent=not isinstance(reason, (socket.gaierror, ConnectionRefusedError))) from None
75
+ try:
76
+ return json.loads(text) if text.strip() else None
77
+ except json.JSONDecodeError:
78
+ raise RunPodError(0, f"an answer that is not JSON: {text[:200]!r}") from None
79
+
80
+ def create_pod(self, body: dict) -> dict:
81
+ return self.call("POST", "/pods", body)
82
+
83
+ def pod(self, pod_id: str) -> dict | None:
84
+ """The pod, or None once it is gone (404)."""
85
+ try:
86
+ return self.call("GET", f"/pods/{pod_id}")
87
+ except RunPodError as e:
88
+ if e.status == 404:
89
+ return None
90
+ raise
91
+
92
+ def delete_pod(self, pod_id: str):
93
+ try:
94
+ self.call("DELETE", f"/pods/{pod_id}")
95
+ except RunPodError as e:
96
+ if e.status != 404: # already gone
97
+ raise
98
+
99
+ def pods(self) -> list[dict]:
100
+ return self.call("GET", "/pods") or []
@@ -0,0 +1,552 @@
1
+ """The coordinator: one per run, state in SQLite, so a run survives the coordinator.
2
+
3
+ Task states: pending (waiting on inputs or a retry delay) -> ready (inputs committed) -> running (submitted, leased
4
+ to this coordinator) -> succeeded | cached | failed | canceled. (`leased` is the brief state between choosing a
5
+ backend and the submit returning.)
6
+
7
+ Guarantees (spec, "Scheduler and execution policy"):
8
+ - A node is scheduled only when every input is committed.
9
+ - At-least-once execution, effectively-once publication: outputs are validated, then published under their content
10
+ hash, then the task row is updated, in that order. A crash between the steps leaves either nothing or an
11
+ unreferenced object, never a half-written artifact.
12
+ - A restarted coordinator (resume) looks at every task the previous one left running: if its process is still
13
+ alive it is adopted and waited for; if it finished, its response is collected; otherwise it is retried.
14
+ - Transient errors (and a process that died without a response) are retried with backoff, up to policy.retries.
15
+ input / schema / privacy / licence / internal errors are terminal.
16
+ - Independent nodes run in parallel within CPU, memory and GPU budgets; `gpu: exclusive` nodes run one at a time.
17
+ - A hard cost cap: a billable node is not submitted if its estimate would exceed what the run has left.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import stat
27
+ import time
28
+ import uuid
29
+ from dataclasses import dataclass
30
+ from pathlib import Path, PurePath
31
+
32
+ from . import artifacts
33
+ from .backends.base import Backend, child_env
34
+ from .ir import CompiledNode, Graph, Policy, compile_graph
35
+ from .manifests import Manifest, Registry, Runner
36
+ from .store import Artifact, Store, dumps
37
+
38
+ TERMINAL = {"succeeded", "cached", "failed", "canceled"}
39
+ DONE = {"succeeded", "cached"}
40
+ LICENCE_ORDER = ["commercial-ok", "api-terms", "needs-review", "academic-only"]
41
+
42
+
43
+ def output_path(out_dir: Path, rel) -> tuple[Path | None, str]:
44
+ """Where output `rel` is, checked without trusting anything the node could have changed: `rel` must be a plain
45
+ relative path (checked before touching the disk), and no step from `out_dir` itself down to it may be a symlink.
46
+ Nothing is resolved: a node (or a container) can replace any of these entries with a link to a host path."""
47
+ parts = PurePath(rel).parts if isinstance(rel, str) and rel else ()
48
+ if not parts or PurePath(rel).is_absolute() or PurePath(rel).drive or any(p in (".", "..") for p in rel.replace("\\", "/").split("/")):
49
+ return None, f"{rel!r} is not a plain relative path"
50
+ steps = [out_dir]
51
+ for part in parts:
52
+ steps.append(steps[-1] / part)
53
+ for i, current in enumerate(steps):
54
+ try:
55
+ mode = os.lstat(current).st_mode
56
+ except FileNotFoundError:
57
+ return None, f"{rel} is missing"
58
+ if stat.S_ISLNK(mode):
59
+ return None, f"{current.name} is a symlink"
60
+ if i < len(steps) - 1 and not stat.S_ISDIR(mode):
61
+ return None, f"{current.name} is not a directory"
62
+ return steps[-1], ""
63
+
64
+
65
+ def manifest_cost(m: Manifest, config: dict, inputs: dict[str, Artifact]) -> float:
66
+ """A billable node's upper estimate: its manifest's price per unit (for this config) times the units in its input."""
67
+ if m.cost is None:
68
+ return 0.0
69
+ if m.cost.per == "run":
70
+ return m.cost.unit_usd(config)
71
+ doc = json.loads(inputs[m.cost.input].path.read_text())
72
+ units = len(doc["shots"]) if m.cost.per == "shot" else doc["duration"] / 60
73
+ return round(m.cost.unit_usd(config) * units, 4)
74
+
75
+
76
+ class LeaseHeld(RuntimeError):
77
+ pass
78
+
79
+
80
+ @dataclass
81
+ class Placement:
82
+ backend: Backend
83
+ runner: Runner
84
+
85
+ @property
86
+ def label(self) -> str:
87
+ """Where it runs, as a graph's `place` and a task's runner name it: `native:mps`, `native:cpu`..."""
88
+ return f"{self.backend.kind}:{self.runner.accelerator}"
89
+
90
+
91
+ class Coordinator:
92
+ def __init__(self, store: Store, registry: Registry, backends: list[Backend], secrets: dict[str, str] | None = None,
93
+ lease_s: float = 30.0, tick_s: float = 0.2, backoff_s: float = 2.0):
94
+ self.store, self.registry = store, registry
95
+ self.backends = {b.kind: b for b in backends}
96
+ self.secrets = secrets or {}
97
+ self.lease_s, self.tick_s, self.backoff_s = lease_s, tick_s, backoff_s
98
+ self.owner = uuid.uuid4().hex
99
+
100
+ # --- runs -----------------------------------------------------------------------------------------------
101
+
102
+ def create_run(self, graph: Graph, graph_doc: dict, allow_uploads: bool = False) -> str:
103
+ """A run of `graph`. `allow_uploads`: its nodes may send data off the machine (within the graph's
104
+ policy.allow_upload); without it a node that would is failed before it starts, whenever it comes to run."""
105
+ run_id = time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
106
+ inputs = {name: self._graph_input(graph, name).to_json() for name in graph.spec.inputs}
107
+ with self.store.tx() as db:
108
+ db.execute("INSERT INTO runs (id, pipeline, graph, graph_path, inputs, status, created_at, max_cost_usd, allow_uploads) VALUES (?,?,?,?,?,?,?,?,?)",
109
+ (run_id, graph.spec.pipeline, dumps(graph_doc), str(graph.source) if graph.source else None,
110
+ dumps(inputs), "running", time.time(), graph.spec.policy.max_cost_usd, int(allow_uploads)))
111
+ for node_id in graph.nodes:
112
+ db.execute("INSERT INTO tasks (run_id, node_id, state) VALUES (?,?,?)", (run_id, node_id, "pending"))
113
+ self.store.event(run_id, None, "run.created", f"{len(graph.nodes)} nodes")
114
+ return run_id
115
+
116
+ def allow_uploads(self, run_id: str):
117
+ """Let the run's nodes send data off the machine from now on (resume or rerun --allow-uploads)."""
118
+ with self.store.tx() as db:
119
+ db.execute("UPDATE runs SET allow_uploads=1 WHERE id=?", (run_id,))
120
+
121
+ def graph_of(self, run_id: str) -> Graph:
122
+ row = self.store.db.execute("SELECT graph, graph_path FROM runs WHERE id=?", (run_id,)).fetchone()
123
+ if row is None:
124
+ raise KeyError(f"no run {run_id}")
125
+ graph = compile_graph(json.loads(row["graph"]), self.registry)
126
+ graph.source = Path(row["graph_path"]) if row["graph_path"] else None
127
+ return graph
128
+
129
+ def rerun(self, run_id: str, from_node: str, use_cache: bool = True):
130
+ """Reset `from_node` and everything downstream of it. Without the cache (`use_cache=False`) only `from_node`
131
+ is forced to run: downstream nodes run again only if its new outputs differ, and otherwise come from the
132
+ cache (spec: rerun only nodes whose declared inputs change)."""
133
+ graph = self.graph_of(run_id)
134
+ if from_node not in graph.nodes:
135
+ raise KeyError(f"run {run_id} has no node {from_node}")
136
+ reset = {from_node} | graph.descendants(from_node)
137
+ with self.store.tx() as db:
138
+ for node_id in reset:
139
+ db.execute("""UPDATE tasks SET state='pending', first_attempt=attempt+1, cache_key=NULL, runner=NULL, lease_owner=NULL,
140
+ lease_expires=NULL, handle=NULL, not_before=NULL, started_at=NULL, finished_at=NULL,
141
+ error_code=NULL, error=NULL, cost_usd=0, usage=NULL, outputs=NULL, skip_cache=?
142
+ WHERE run_id=? AND node_id=?""", (0 if use_cache or node_id != from_node else 1, run_id, node_id))
143
+ db.execute("UPDATE runs SET status='running', finished_at=NULL WHERE id=?", (run_id,))
144
+ self.store.event(run_id, None, "run.rerun", f"from {from_node}: {', '.join(sorted(reset))}")
145
+
146
+ def cancel(self, run_id: str):
147
+ """Record the cancel first, then kill: once a task is canceled, a coordinator that sees its process die reports
148
+ nothing (_attempt_failed ignores tasks no longer running), so the kill is charged once, here."""
149
+ with self.store.tx() as db:
150
+ running = []
151
+ for t in self._tasks(run_id).values():
152
+ if t["state"] == "leased" and t["runner"]: # perhaps started before its coordinator recorded it running
153
+ handle = self.backends[t["runner"].split(":")[0]].recorded_handle(
154
+ self.store.root / "work" / run_id / t["node_id"] / str(t["attempt"]))
155
+ if handle:
156
+ t = {**t, "handle": json.dumps(handle)}
157
+ if t["state"] in ("running", "leased") and t["handle"]:
158
+ running.append(t)
159
+ # a killed attempt may have spent up to its reservation: it is charged that, as a crash is, and the
160
+ # reservation is used up (cost_usd 0), so nothing else charges it again
161
+ db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (sum(t["cost_usd"] or 0.0 for t in running), run_id))
162
+ for t in running:
163
+ db.execute("UPDATE tasks SET cost_usd=0 WHERE run_id=? AND node_id=?", (run_id, t["node_id"]))
164
+ db.execute("UPDATE tasks SET state='canceled', finished_at=? WHERE run_id=? AND state NOT IN ('succeeded','cached','failed')",
165
+ (time.time(), run_id))
166
+ db.execute("UPDATE runs SET status='canceled', finished_at=? WHERE id=?", (time.time(), run_id))
167
+ for t in running:
168
+ self.backends[t["runner"].split(":")[0]].cancel(json.loads(t["handle"]))
169
+ self.store.event(run_id, None, "run.canceled")
170
+
171
+ # --- the loop -------------------------------------------------------------------------------------------
172
+
173
+ async def run(self, run_id: str) -> str:
174
+ graph = self.graph_of(run_id)
175
+ self._recover(run_id, graph)
176
+ last_heartbeat = 0.0
177
+ while True:
178
+ if self._canceled(run_id):
179
+ return self._stop_canceled(run_id)
180
+ tasks = self._tasks(run_id)
181
+ for t in tasks.values():
182
+ if t["state"] == "running" and t["lease_owner"] == self.owner:
183
+ self._check_running(run_id, graph, t)
184
+ tasks = self._tasks(run_id)
185
+ self._promote(run_id, graph, tasks)
186
+ tasks = self._tasks(run_id)
187
+ for node_id in graph.nodes: # topological order: upstream nodes are placed first
188
+ if tasks[node_id]["state"] == "ready":
189
+ self._start(run_id, graph, graph.nodes[node_id], tasks)
190
+ tasks = self._tasks(run_id)
191
+ if time.time() - last_heartbeat > self.lease_s / 3:
192
+ self.store.db.execute("UPDATE tasks SET lease_expires=? WHERE run_id=? AND lease_owner=? AND state='running'",
193
+ (time.time() + self.lease_s, run_id, self.owner))
194
+ last_heartbeat = time.time()
195
+ if all(t["state"] in TERMINAL for t in tasks.values()):
196
+ if self._canceled(run_id): # canceled since the top of this pass
197
+ return self._stop_canceled(run_id)
198
+ status = "succeeded" if all(t["state"] in DONE for t in tasks.values()) else "failed"
199
+ self.store.db.execute("UPDATE runs SET status=?, finished_at=? WHERE id=?", (status, time.time(), run_id))
200
+ self.store.event(run_id, None, f"run.{status}")
201
+ return status
202
+ await asyncio.sleep(self.tick_s) # the loop's own pace: node processes run independently of it
203
+
204
+ def _canceled(self, run_id: str) -> bool:
205
+ return self.store.db.execute("SELECT status FROM runs WHERE id=?", (run_id,)).fetchone()["status"] == "canceled"
206
+
207
+ def _stop_canceled(self, run_id: str) -> str:
208
+ """The run was canceled, perhaps by another process (cancel() recorded it and said so). This coordinator works from
209
+ snapshots, so it may have retried or started a node since: stop what it has running, and leave every unfinished
210
+ task canceled."""
211
+ killed = 0.0
212
+ for t in self._tasks(run_id).values():
213
+ if t["state"] in ("running", "leased") and t["lease_owner"] == self.owner and t["handle"]:
214
+ self.backends[t["runner"].split(":")[0]].cancel(json.loads(t["handle"]))
215
+ killed += t["cost_usd"] or 0.0 # charged as cancel() charges what it kills
216
+ with self.store.tx():
217
+ self.store.db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (killed, run_id))
218
+ self.store.db.execute("UPDATE tasks SET state='canceled', finished_at=COALESCE(finished_at, ?) WHERE run_id=? AND state NOT IN ('succeeded','cached','failed','canceled')",
219
+ (time.time(), run_id))
220
+ return "canceled"
221
+
222
+ def _tasks(self, run_id: str) -> dict[str, dict]:
223
+ return {r["node_id"]: dict(r) for r in self.store.db.execute("SELECT * FROM tasks WHERE run_id=?", (run_id,))}
224
+
225
+ def _recover(self, run_id: str, graph: Graph):
226
+ """Take over a run another coordinator left (it crashed, or was stopped). Only expired leases are taken: a
227
+ coordinator that is still alive renews its leases every lease_s / 3."""
228
+ tasks = self._tasks(run_id)
229
+ live = [t for t in tasks.values() if t["state"] in ("leased", "running") and t["lease_owner"] not in (None, self.owner)
230
+ and (t["lease_expires"] or 0) > time.time()]
231
+ if live:
232
+ wait = max(t["lease_expires"] for t in live) - time.time()
233
+ raise LeaseHeld(f"run {run_id} is held by another coordinator (its lease on {live[0]['node_id']} runs "
234
+ f"{wait:.0f} s more); if that process is gone, resume after the lease expires")
235
+ self.store.db.execute("UPDATE runs SET status='running', finished_at=NULL WHERE id=? AND status!='canceled'", (run_id,))
236
+ for t in tasks.values():
237
+ if t["state"] == "leased":
238
+ # chosen; perhaps started (its process recorded a handle) before the coordinator could record it running
239
+ handle = self.backends[t["runner"].split(":")[0]].recorded_handle(
240
+ self.store.root / "work" / run_id / t["node_id"] / str(t["attempt"])) if t["runner"] else None
241
+ if handle is None: # never submitted
242
+ self._set(run_id, t["node_id"], state="pending", lease_owner=None, lease_expires=None)
243
+ continue
244
+ self._set(run_id, t["node_id"], state="running", handle=dumps(handle), started_at=time.time())
245
+ t = self._tasks(run_id)[t["node_id"]]
246
+ if t["state"] == "running" and t["lease_owner"] != self.owner:
247
+ backend = self.backends[t["runner"].split(":")[0]]
248
+ handle = json.loads(t["handle"])
249
+ still = backend.poll(handle)
250
+ self._set(run_id, t["node_id"], lease_owner=self.owner, lease_expires=time.time() + self.lease_s)
251
+ self.store.event(run_id, t["node_id"], "task.adopted", f"attempt {t['attempt']} {'still running' if still == 'running' else 'finished while no coordinator watched'}")
252
+
253
+ def _promote(self, run_id: str, graph: Graph, tasks: dict[str, dict]):
254
+ now = time.time()
255
+ for node_id, node in graph.nodes.items():
256
+ t = tasks[node_id]
257
+ if t["state"] != "pending" or (t["not_before"] and t["not_before"] > now):
258
+ continue
259
+ ups = [tasks[u]["state"] for u in node.upstream]
260
+ if any(s in ("failed", "canceled") for s in ups):
261
+ self._set(run_id, node_id, state="canceled", error_code="upstream", error="an upstream node failed", finished_at=now)
262
+ self.store.event(run_id, node_id, "task.canceled", "upstream failed")
263
+ elif all(s in DONE for s in ups):
264
+ self._set(run_id, node_id, state="ready")
265
+
266
+ # --- starting a task ------------------------------------------------------------------------------------
267
+
268
+ def _inputs(self, run_id: str, graph: Graph, node: CompiledNode, tasks: dict[str, dict]) -> dict[str, Artifact]:
269
+ run_inputs = json.loads(self.store.db.execute("SELECT inputs FROM runs WHERE id=?", (run_id,)).fetchone()["inputs"])
270
+ out = {}
271
+ for name, ref in node.inputs.items():
272
+ if ref.node is None:
273
+ out[name] = Artifact.from_json(run_inputs[ref.name])
274
+ else:
275
+ out[name] = Artifact.from_json(json.loads(tasks[ref.node]["outputs"])[ref.name])
276
+ return out
277
+
278
+ def placements(self, manifest: Manifest, wanted: str = "auto", policy: Policy | None = None) -> tuple[list[Placement], str]:
279
+ """Every place this node can run, best first: this machine's (accelerated runners before CPU ones), then other
280
+ machines' (lab servers, cloud), which a graph's policy must allow: they receive the node's inputs. Why none,
281
+ when none."""
282
+ options, reasons = self._placements(manifest, wanted, policy)
283
+ return options, "" if options else "; ".join(f"{k}: {v}" for k, v in reasons.items()) or \
284
+ f"no runner of kind {wanted if wanted != 'auto' else 'native or self'}"
285
+
286
+ def _placements(self, manifest: Manifest, wanted: str, policy: Policy | None) -> tuple[list[Placement], dict[str, str]]:
287
+ """The places, and why each backend that offers none cannot run it (backends without its kind of runner aside)."""
288
+ local = [b for b in self.backends.values() if not b.remote]
289
+ remote = [b for b in self.backends.values() if b.remote]
290
+ chosen = [self.backends[wanted]] if wanted != "auto" and wanted in self.backends else local + remote if wanted == "auto" else []
291
+ options, reasons = [], {}
292
+ for backend in chosen:
293
+ runners, why = backend.runners_for(manifest)
294
+ # Apple silicon's memory is shared by CPU and GPU: a node that runs on the GPU there has no reason to be
295
+ # offered the CPU too (a CUDA machine's CPU fallback stays a choice: its GPU memory is its own)
296
+ if any(r.accelerator == "mps" for r in runners):
297
+ runners = [r for r in runners if r.accelerator != "cpu"]
298
+ if runners and backend.remote and (refusal := self.remote_policy(manifest, backend, policy)):
299
+ runners, why = [], refusal
300
+ options += [Placement(backend, r) for r in runners]
301
+ if not runners and not why.startswith("no "):
302
+ reasons[backend.kind] = why
303
+ return options, reasons
304
+
305
+ @staticmethod
306
+ def remote_policy(manifest: Manifest, backend: Backend, policy: Policy | None) -> str:
307
+ """Why the graph's policy keeps this node off another machine, or "". Without a graph (doctor, nodes), only
308
+ what could run there is asked, not what may."""
309
+ if policy is None:
310
+ return ""
311
+ if policy.placement == "local" or policy.privacy == "local-only":
312
+ return "the graph keeps its nodes on this machine (policy.placement / policy.privacy)"
313
+ if not_allowed := [k for k in backend.sends(manifest) if k not in policy.allow_upload]:
314
+ return f"its inputs hold {', '.join(not_allowed)}, which policy.allow_upload does not let leave this machine"
315
+ return ""
316
+
317
+ def place(self, manifest: Manifest, wanted: str = "auto", choice: str | None = None,
318
+ policy: Policy | None = None) -> tuple[Placement | None, str]:
319
+ """Where this node runs: the graph's choice (`place`, as `native:cpu` or `ssh-gpu1:cuda`), else the best place
320
+ on this machine. Another machine is only ever chosen, never a default: it receives the node's inputs."""
321
+ options, reasons = self._placements(manifest, wanted, policy)
322
+ why = "" if options else "; ".join(f"{k}: {v}" for k, v in reasons.items()) or f"no runner of kind {wanted}"
323
+ if choice is None:
324
+ here = [p for p in options if not p.backend.remote]
325
+ if here:
326
+ return here[0], ""
327
+ if options:
328
+ return None, f"it cannot run on this machine; it can run on {', '.join(p.label for p in options)} (choose one with --place)"
329
+ return None, why
330
+ chosen = next((p for p in options if p.label == choice), None)
331
+ if chosen is None:
332
+ listed = f" ({', '.join(p.label for p in options)})" if options else ""
333
+ reason = reasons.get(choice.split(":")[0]) or (why if not options else "")
334
+ return None, f"{choice} is not one of the places it can run{listed}" + (f": {reason}" if reason else "")
335
+ return chosen, ""
336
+
337
+ def uploads(self, manifest: Manifest, placement: Placement) -> tuple[list[str], str | None]:
338
+ """The kinds of data that leave this machine when the node runs at `placement`, and where they go (None: nothing
339
+ leaves): to the API it calls (its manifest's privacy), or with its inputs to the other machine it runs on. Running
340
+ on another machine always sends something there, even inputs that hold none of the kinds."""
341
+ if manifest.privacy.external_upload:
342
+ return list(manifest.privacy.external_upload), manifest.service or "an API"
343
+ if placement.backend.remote:
344
+ return placement.backend.sends(manifest), placement.backend.title
345
+ return [], None
346
+
347
+ def cache_key(self, node: CompiledNode, placement: Placement, inputs: dict[str, Artifact]) -> str:
348
+ m = node.manifest
349
+ key = {
350
+ "node": m.ref,
351
+ "runner": placement.backend.identity(m, placement.runner),
352
+ "config": node.config,
353
+ "inputs": {name: [a.type, a.hash] for name, a in sorted(inputs.items())},
354
+ "outputs": m.outputs,
355
+ "partition": None,
356
+ }
357
+ return hashlib.sha256(artifacts.canonical(key).encode()).hexdigest()
358
+
359
+ def _start(self, run_id: str, graph: Graph, node: CompiledNode, tasks: dict[str, dict]):
360
+ t, m = tasks[node.id], node.manifest
361
+ placement, why = self.place(m, node.spec.backend, node.spec.place, graph.spec.policy)
362
+ if placement is None:
363
+ self._fail(run_id, node.id, "input", f"no backend can run {m.ref}: {why}")
364
+ return
365
+ inputs = self._inputs(run_id, graph, node, tasks)
366
+ key = self.cache_key(node, placement, inputs)
367
+
368
+ if not t["skip_cache"]:
369
+ hit = self.store.db.execute("SELECT outputs, usage FROM cache WHERE key=?", (key,)).fetchone()
370
+ if hit:
371
+ outs = {k: Artifact.from_json(v) for k, v in json.loads(hit["outputs"]).items()}
372
+ if all(self.store.has(a) for a in outs.values()):
373
+ self._set(run_id, node.id, state="cached", cache_key=key, outputs=dumps({k: a.to_json() for k, a in outs.items()}),
374
+ usage=hit["usage"], finished_at=time.time())
375
+ self.store.event(run_id, node.id, "task.cached", key[:12])
376
+ return
377
+
378
+ # consent to send data off is the run's (--allow-uploads), checked when the node is about to run: a resume or a
379
+ # rerun, or a cache that is gone, cannot make a node upload in a run that was not allowed to
380
+ sent, to = self.uploads(m, placement)
381
+ if to and not self.store.db.execute("SELECT allow_uploads FROM runs WHERE id=?", (run_id,)).fetchone()[0]:
382
+ self._fail(run_id, node.id, "policy", f"{m.ref} sends {', '.join(sent) or 'its inputs'} off this machine (to {to}), "
383
+ "and this run may not (--allow-uploads)")
384
+ return
385
+ if not self._fits(run_id, graph, m, placement, tasks):
386
+ return # stays ready; started when running nodes free their share
387
+ estimate = placement.backend.estimate(m, placement.runner, inputs)
388
+ estimate.cost_usd += manifest_cost(m, node.config, inputs)
389
+ run = self.store.db.execute("SELECT max_cost_usd, spent_usd FROM runs WHERE id=?", (run_id,)).fetchone()
390
+ reserved = sum(x["cost_usd"] or 0.0 for x in tasks.values() if x["state"] in ("leased", "running"))
391
+ if estimate.cost_usd > 0 and run["spent_usd"] + reserved + estimate.cost_usd > run["max_cost_usd"]:
392
+ self._fail(run_id, node.id, "budget", f"estimated ${estimate.cost_usd:.2f} would exceed the run's cap of ${run['max_cost_usd']:.2f}")
393
+ return
394
+
395
+ if self._canceled(run_id): # the last moment before anything starts or is reserved
396
+ return
397
+ attempt = t["attempt"] + 1
398
+ attempt_dir = self.store.root / "work" / run_id / node.id / str(attempt)
399
+ attempt_dir.mkdir(parents=True, exist_ok=False)
400
+ # reserved from the moment it is chosen: a coordinator that dies before recording it running leaves the
401
+ # reservation with the task that the next one adopts
402
+ self._set(run_id, node.id, state="leased", attempt=attempt, cache_key=key, lease_owner=self.owner,
403
+ lease_expires=time.time() + self.lease_s, runner=placement.label,
404
+ cost_usd=estimate.cost_usd)
405
+ request = {
406
+ "protocol": 1, "run": run_id, "node": node.id, "uses": m.ref, "attempt": attempt, "partition": None,
407
+ "accelerator": placement.runner.accelerator,
408
+ "inputs": placement.backend.stage_inputs(attempt_dir, inputs), "config": node.config,
409
+ "out_dir": str(placement.backend.output_dir(attempt_dir)), "secrets": m.secrets,
410
+ "producer": {"stage": m.stage or m.id, "stage_version": m.version, "models": [x.model_dump(exclude={"size_gb", "gated"}) for x in m.models]},
411
+ }
412
+ (attempt_dir / "request.json").write_text(json.dumps(request, indent=1))
413
+ try:
414
+ handle = placement.backend.submit(m, placement.runner, attempt_dir, child_env(m.secrets, self.secrets))
415
+ except Exception as e: # could not even start (a missing tool): transient, the retry policy decides
416
+ self._attempt_failed(run_id, graph, node.id, attempt, "transient", f"submit failed: {e}")
417
+ return
418
+ with self.store.tx(): # recorded running only if nothing (a cancel) changed the task while it was submitted
419
+ now = self._tasks(run_id)[node.id]
420
+ if now["state"] != "leased" or self._canceled(run_id):
421
+ placement.backend.cancel(handle) # canceled meanwhile: stop it now; it may have begun spending
422
+ # charged what is left of its reservation (cancel() charges it, and uses it up, when it saw the process)
423
+ self.store.db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (now["cost_usd"] or 0.0, run_id))
424
+ self._set(run_id, node.id, handle=dumps(handle), cost_usd=0)
425
+ return
426
+ self._set(run_id, node.id, state="running", handle=dumps(handle), started_at=time.time(), cost_usd=estimate.cost_usd)
427
+ self.store.event(run_id, node.id, "task.started", f"attempt {attempt} on {placement.backend.kind} ({placement.runner.accelerator})")
428
+
429
+ def _fits(self, run_id: str, graph: Graph, m: Manifest, placement: Placement, tasks: dict[str, dict]) -> bool:
430
+ """Within the CPU, memory and GPU of the machine it would run on, beside the nodes already running there."""
431
+ machine = placement.backend.machine
432
+ if machine is None:
433
+ return True # a machine of its own
434
+ caps = placement.backend.probe_capabilities()
435
+ running = [graph.nodes[i].manifest.resources for i, x in tasks.items() if x["state"] in ("running", "leased")
436
+ and x["runner"] and self.backends[x["runner"].split(":")[0]].machine == machine]
437
+ if not running:
438
+ return True # a node bigger than the machine still runs, alone
439
+ r = m.resources
440
+ if r.gpu == "exclusive" and any(x.gpu != "none" for x in running):
441
+ return False
442
+ if r.gpu != "none" and any(x.gpu == "exclusive" for x in running):
443
+ return False
444
+ cpu = sum(x.cpu_cores for x in running) + r.cpu_cores
445
+ mem = sum(x.memory_gb for x in running) + r.memory_gb
446
+ return cpu <= caps.cpu_cores and mem <= caps.memory_gb * 0.8
447
+
448
+ # --- finishing a task -----------------------------------------------------------------------------------
449
+
450
+ def _check_running(self, run_id: str, graph: Graph, t: dict):
451
+ backend = self.backends[t["runner"].split(":")[0]]
452
+ handle = json.loads(t["handle"])
453
+ if backend.poll(handle) == "running":
454
+ return
455
+ attempt_dir = self.store.root / "work" / run_id / t["node_id"] / str(t["attempt"])
456
+ response = backend.collect(attempt_dir)
457
+ backend.cleanup(attempt_dir)
458
+ node = graph.nodes[t["node_id"]]
459
+ if response is None:
460
+ # it reported nothing, so it may have spent up to what was reserved for it: charge that
461
+ self._attempt_failed(run_id, graph, node.id, t["attempt"], "transient",
462
+ f"the node exited without a response (crashed or killed); see {attempt_dir / 'log.txt'}",
463
+ usd=t["cost_usd"] or 0.0)
464
+ return
465
+ spent = float(response.get("usage", {}).get("usd", 0.0)) # what a failed attempt spent is still spent
466
+ if not response["ok"]:
467
+ err = response["error"]
468
+ self._attempt_failed(run_id, graph, node.id, t["attempt"], err["code"], err["message"], usd=spent)
469
+ return
470
+ problems, outs = self._publish(node, backend.output_dir(attempt_dir), response)
471
+ if problems:
472
+ self._attempt_failed(run_id, graph, node.id, t["attempt"], problems[0][0], "; ".join(p for _, p in problems), usd=spent)
473
+ return
474
+ usage = {**response.get("usage", {}), "finished_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())}
475
+ cost = float(usage.get("usd", 0.0))
476
+ out_json = dumps({k: a.to_json() for k, a in outs.items()})
477
+ with self.store.tx() as db:
478
+ now = self._tasks(run_id)[node.id]
479
+ if now["state"] != "running" or now["attempt"] != t["attempt"]:
480
+ return # canceled (and charged its reservation) while it finished: it stays canceled
481
+ db.execute("INSERT OR REPLACE INTO cache VALUES (?,?,?,?,?)", (t["cache_key"], node.manifest.ref, out_json, dumps(usage), time.time()))
482
+ db.execute("""UPDATE tasks SET state='succeeded', outputs=?, usage=?, cost_usd=?, finished_at=?, lease_owner=NULL,
483
+ lease_expires=NULL, error_code=NULL, error=NULL WHERE run_id=? AND node_id=?""",
484
+ (out_json, dumps(usage), cost, time.time(), run_id, node.id))
485
+ db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (cost, run_id))
486
+ self.store.event(run_id, node.id, "task.succeeded", f"{usage.get('seconds', '?')} s")
487
+
488
+ def _publish(self, node: CompiledNode, out_dir: Path, response: dict) -> tuple[list[tuple[str, str]], dict[str, Artifact]]:
489
+ """Validate every declared output, check the models it reports, then publish. Nothing is published unless all
490
+ of it is valid."""
491
+ m, problems, ready = node.manifest, [], {}
492
+ declared = {x.id: x.licence_mode for x in m.models}
493
+ for used in response.get("models", []):
494
+ if used["id"] not in declared:
495
+ problems.append(("licence", f"the node used {used['id']}, which its manifest does not declare"))
496
+ elif LICENCE_ORDER.index(used["licence_mode"]) > LICENCE_ORDER.index(declared[used["id"]]):
497
+ problems.append(("licence", f"{used['id']} reported {used['licence_mode']}, stricter than the manifest's {declared[used['id']]}"))
498
+ for name, type_id in m.outputs.items():
499
+ entry = response["outputs"].get(name)
500
+ if entry is None:
501
+ problems.append(("schema", f"output {name} was not written"))
502
+ continue
503
+ path, why = output_path(out_dir, entry.get("path"))
504
+ if path is None:
505
+ problems.append(("schema", f"output {name}: {why}"))
506
+ continue
507
+ special = artifacts.special_files(path)
508
+ if special:
509
+ problems.append(("schema", f"output {name}: contains a symlink or special file: {', '.join(special[:5])}"))
510
+ continue
511
+ for p in artifacts.get(type_id).validate(path):
512
+ problems.append(("schema", f"output {name} ({type_id}): {p}"))
513
+ ready[name] = (path, type_id)
514
+ if problems:
515
+ return problems, {}
516
+ return [], {name: self.store.publish(path, type_id) for name, (path, type_id) in ready.items()}
517
+
518
+ def _attempt_failed(self, run_id: str, graph: Graph, node_id: str, attempt: int, code: str, message: str, usd: float = 0.0):
519
+ """Retry a transient failure within the policy, else fail. `usd`, what the attempt spent, is charged in the same
520
+ transaction: a coordinator that dies in between must not charge it again on resume."""
521
+ retries = graph.spec.policy.retries
522
+ with self.store.tx():
523
+ now = self._tasks(run_id)[node_id]
524
+ if now["state"] not in ("running", "leased") or now["attempt"] != attempt:
525
+ return # a stale report: the task was canceled (and charged) or has moved on since it was read
526
+ first = now["first_attempt"]
527
+ tried = attempt - first + 1
528
+ if usd:
529
+ self.store.db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (usd, run_id))
530
+ if code == "transient" and tried <= retries:
531
+ delay = self.backoff_s * 2 ** (tried - 1)
532
+ self._set(run_id, node_id, state="pending", not_before=time.time() + delay, lease_owner=None, lease_expires=None,
533
+ handle=None, error_code=code, error=message)
534
+ self.store.event(run_id, node_id, "task.retry", f"attempt {attempt} failed ({message}); retry in {delay:.0f} s")
535
+ else:
536
+ self._fail(run_id, node_id, code, message)
537
+
538
+ def _fail(self, run_id: str, node_id: str, code: str, message: str):
539
+ self._set(run_id, node_id, state="failed", error_code=code, error=message, finished_at=time.time(), lease_owner=None, lease_expires=None)
540
+ self.store.event(run_id, node_id, "task.failed", f"{code}: {message}")
541
+
542
+ def _set(self, run_id: str, node_id: str, **fields):
543
+ cols = ", ".join(f"{k}=?" for k in fields)
544
+ self.store.db.execute(f"UPDATE tasks SET {cols} WHERE run_id=? AND node_id=?", (*fields.values(), run_id, node_id))
545
+
546
+ def _graph_input(self, graph: Graph, name: str) -> Artifact:
547
+ spec = graph.spec.inputs[name]
548
+ path = graph.input_path(name)
549
+ if not path.exists():
550
+ raise FileNotFoundError(f"graph input {name}: {path} does not exist")
551
+ kind = artifacts.get(spec.type)
552
+ return self.store.source(path, spec.type) if kind.shape == "source" else self.store.publish(path, spec.type)