badgr-cli 1.1.6 → 1.1.8

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.
@@ -4,11 +4,19 @@ function round1(n) {
4
4
  return Math.round(n * 10) / 10;
5
5
  }
6
6
 
7
+ function bytesToGb(n) {
8
+ return round1(Number(n) / (1024 ** 3));
9
+ }
10
+
7
11
  /**
8
- * Read-only nvidia-smi probe. Never mutates the machine.
12
+ * Read-only nvidia-smi probe. Returns null when nvidia-smi isn't installed
13
+ * (ENOENT) so the caller can fall back to rocm-smi; any other failure
14
+ * (permission denied, driver broken, etc.) is a real NVIDIA-specific error
15
+ * and is returned as such, not treated as "try the other vendor".
9
16
  */
10
- export function detectGpus(execImpl = execFileSync) {
17
+ function _probeNvidia(execImpl) {
11
18
  const result = {
19
+ vendor: 'nvidia',
12
20
  available: false,
13
21
  error: null,
14
22
  driverVersion: null,
@@ -21,7 +29,8 @@ export function detectGpus(execImpl = execFileSync) {
21
29
  try {
22
30
  smiOut = execImpl('nvidia-smi', [], { encoding: 'utf8', timeout: 5000 });
23
31
  } catch (err) {
24
- result.error = err && err.code === 'ENOENT' ? 'nvidia-smi not found' : (err && err.message) || 'nvidia-smi failed';
32
+ if (err && err.code === 'ENOENT') return null;
33
+ result.error = (err && err.message) || 'nvidia-smi failed';
25
34
  return result;
26
35
  }
27
36
 
@@ -33,12 +42,20 @@ export function detectGpus(execImpl = execFileSync) {
33
42
  if (cudaMatch) result.cudaVersion = cudaMatch[1];
34
43
 
35
44
  try {
45
+ // compute_cap (e.g. "8.9" for Ada Lovelace, "9.0" for Hopper) is the
46
+ // real, stable NVIDIA architecture identifier -- the same value used in
47
+ // TORCH_CUDA_ARCH_LIST -- and is what byo_preflight.py's
48
+ // required_gpu_architecture check compares node_hardware.gpu_architecture
49
+ // against. Supported by nvidia-smi on any reasonably current driver;
50
+ // older drivers that reject the field fall into the catch below and
51
+ // simply leave architecture null per GPU, same as any other unsupported
52
+ // CSV field here.
36
53
  const csv = execImpl('nvidia-smi', [
37
- '--query-gpu=index,name,memory.total,memory.free,memory.used,temperature.gpu,power.draw',
54
+ '--query-gpu=index,name,memory.total,memory.free,memory.used,temperature.gpu,power.draw,compute_cap',
38
55
  '--format=csv,noheader,nounits',
39
56
  ], { encoding: 'utf8', timeout: 5000 });
40
57
  result.gpus = csv.trim().split(/\r?\n/).filter(Boolean).map((line) => {
41
- const [index, name, total, free, used, temp, power] = line.split(',').map((s) => s.trim());
58
+ const [index, name, total, free, used, temp, power, computeCap] = line.split(',').map((s) => s.trim());
42
59
  return {
43
60
  index: Number(index),
44
61
  name,
@@ -47,6 +64,7 @@ export function detectGpus(execImpl = execFileSync) {
47
64
  vramUsedGb: round1(Number(used) / 1024),
48
65
  temperatureC: temp && temp !== '[N/A]' ? Number(temp) : null,
49
66
  powerDrawW: power && power !== '[N/A]' ? Number(power) : null,
67
+ architecture: computeCap && computeCap !== '[N/A]' ? computeCap : null,
50
68
  };
51
69
  });
52
70
  } catch {
@@ -68,3 +86,124 @@ export function detectGpus(execImpl = execFileSync) {
68
86
 
69
87
  return result;
70
88
  }
89
+
90
+ /**
91
+ * Read-only rocm-smi probe (AMD/ROCm — e.g. Radeon Pro / Instinct cards on
92
+ * gfx9xx/gfx10xx). Same "return null on ENOENT" contract as _probeNvidia so
93
+ * detectGpus can tell "not installed" apart from "installed but failing".
94
+ */
95
+ function _probeRocm(execImpl) {
96
+ const result = {
97
+ vendor: 'amd',
98
+ available: false,
99
+ error: null,
100
+ driverVersion: null,
101
+ cudaVersion: null,
102
+ gpus: [],
103
+ processes: [],
104
+ };
105
+
106
+ let raw;
107
+ try {
108
+ raw = execImpl('rocm-smi', [
109
+ '--showproductname', '--showmeminfo', 'vram', '--showtemp', '--showpower',
110
+ '--showdriverversion', '--showtarget', '--json',
111
+ ], { encoding: 'utf8', timeout: 5000 });
112
+ } catch (err) {
113
+ if (err && err.code === 'ENOENT') return null;
114
+ result.error = (err && err.message) || 'rocm-smi failed';
115
+ return result;
116
+ }
117
+
118
+ result.available = true;
119
+
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(raw);
123
+ } catch {
124
+ // rocm-smi found but this build doesn't support --json (older ROCm) —
125
+ // still "available", just no per-GPU detail to report.
126
+ return result;
127
+ }
128
+
129
+ const cardKeys = Object.keys(parsed)
130
+ .filter((k) => /^card\d+$/.test(k))
131
+ .sort((a, b) => Number(a.slice(4)) - Number(b.slice(4)));
132
+
133
+ const findField = (card, ...needles) => {
134
+ const hit = Object.keys(card).find((k) => {
135
+ const lower = k.toLowerCase();
136
+ return needles.some((n) => lower.includes(n));
137
+ });
138
+ return hit ? card[hit] : undefined;
139
+ };
140
+
141
+ // The exact --showtarget key name has drifted across ROCm releases (e.g.
142
+ // "GPU Target Graphics Version" vs "Card Series" being reused, etc.), so
143
+ // key-name matching alone (as used for every other field above) isn't
144
+ // reliable here. A gfx architecture string (gfx900, gfx1030, gfx1100, ...)
145
+ // is distinctive and never appears as any other field's value, so scan
146
+ // every value on the card for that pattern regardless of which key it
147
+ // landed under -- robust to ROCm's key-naming drift by construction.
148
+ const _GFX_VALUE_RE = /\bgfx[0-9a-fA-F]{3,4}\b/;
149
+ const findArchitecture = (card) => {
150
+ for (const v of Object.values(card)) {
151
+ const m = String(v).match(_GFX_VALUE_RE);
152
+ if (m) return m[0].toLowerCase();
153
+ }
154
+ return null;
155
+ };
156
+
157
+ result.gpus = cardKeys.map((key, i) => {
158
+ const card = parsed[key] || {};
159
+ const totalBytes = findField(card, 'vram total memory');
160
+ const usedBytes = findField(card, 'vram total used memory');
161
+ const temp = findField(card, 'temperature');
162
+ const power = findField(card, 'power');
163
+ const totalGb = totalBytes != null ? bytesToGb(totalBytes) : null;
164
+ const usedGb = usedBytes != null ? bytesToGb(usedBytes) : null;
165
+ return {
166
+ index: i,
167
+ name: findField(card, 'card series', 'card model', 'product name') || 'AMD GPU',
168
+ vramTotalGb: totalGb,
169
+ vramFreeGb: totalGb != null && usedGb != null ? round1(totalGb - usedGb) : null,
170
+ vramUsedGb: usedGb,
171
+ temperatureC: temp != null && !Number.isNaN(parseFloat(temp)) ? Number(parseFloat(temp)) : null,
172
+ powerDrawW: power != null && !Number.isNaN(parseFloat(power)) ? Number(parseFloat(power)) : null,
173
+ architecture: findArchitecture(card),
174
+ };
175
+ });
176
+
177
+ const systemFields = parsed.system || {};
178
+ const driverKey = Object.keys(systemFields).find((k) => k.toLowerCase().includes('driver version'));
179
+ if (driverKey) result.driverVersion = systemFields[driverKey];
180
+ // rocm-smi has no fleet-wide "ROCm version" equivalent to nvidia-smi's CUDA
181
+ // Version header — a workload's own required HSA_OVERRIDE_GFX_VERSION /
182
+ // PYTORCH_ROCM_ARCH stays a launch-command concern, not a hardware fact
183
+ // this probe can report. cudaVersion stays null here on purpose.
184
+
185
+ return result;
186
+ }
187
+
188
+ /**
189
+ * Read-only GPU probe. Tries nvidia-smi first, falls back to rocm-smi
190
+ * (AMD/ROCm) when NVIDIA tooling isn't installed. Never mutates the
191
+ * machine. `vendor` is 'nvidia' | 'amd' | null (neither tool present).
192
+ */
193
+ export function detectGpus(execImpl = execFileSync) {
194
+ const nvidia = _probeNvidia(execImpl);
195
+ if (nvidia) return nvidia;
196
+
197
+ const rocm = _probeRocm(execImpl);
198
+ if (rocm) return rocm;
199
+
200
+ return {
201
+ vendor: null,
202
+ available: false,
203
+ error: 'nvidia-smi and rocm-smi not found',
204
+ driverVersion: null,
205
+ cudaVersion: null,
206
+ gpus: [],
207
+ processes: [],
208
+ };
209
+ }
@@ -0,0 +1,380 @@
1
+ """
2
+ badgr-node — the BYO GPU (Phase 1) worker daemon.
3
+
4
+ Runs on a customer's own machine (registered via `badgr node connect`) and
5
+ implements the loop the locked spec requires: register -> heartbeat ->
6
+ receive job -> execute -> stream status -> complete. Talks only to
7
+ the backend's own /v1/nodes/{node_id}/* endpoints.
8
+
9
+ Execution is plain `docker run`, with GPU passthrough flags chosen from a
10
+ one-time local vendor probe (`_detect_gpu_vendor`): `--gpus all` (the
11
+ standard NVIDIA Container Toolkit flag) on NVIDIA, or the ROCm container
12
+ device flags (`--device=/dev/kfd --device=/dev/dri --group-add video
13
+ --group-add render --security-opt seccomp=unconfined`) on AMD — no
14
+ Slurm/Kubernetes, per this PR's locked scope.
15
+
16
+ The container starts with none of this machine's filesystem visible except
17
+ what's explicitly bind-mounted. A workload whose launch command depends on
18
+ host paths that already exist on this machine (a Python venv, a patched
19
+ source checkout, a model directory, a HuggingFace/pip cache) will not find
20
+ them inside the container unless this worker was started with `--mount
21
+ HOST:CONTAINER[:ro]` (repeatable; same shape as `docker run -v`) for each
22
+ one — see `--mount` in `main()`'s argparse setup and `badgr node connect
23
+ --mount`. These are static, host-bound paths configured once per node, not
24
+ part of a per-job plan: the backend has no visibility into what exists on
25
+ a customer's own machine, so it never sends mount paths — only this
26
+ worker, run by the machine's owner, can name them.
27
+ A "job" workload runs to completion and reports its real exit code. An
28
+ "endpoint" workload (badgr serve) runs detached and is reported "running"
29
+ only after a real readiness check (GET health_path) and, when a model was
30
+ requested, a real minimal completion request (POST /v1/completions) both
31
+ succeed against the container locally — the same two checks
32
+ the backend's own readiness/inference verification logic makes, same URL
33
+ paths and payload shape, just run from here instead of from the backend. That's not a stylistic choice: Badgr's
34
+ backend cannot reach a customer's own machine directly (outbound-only,
35
+ often behind NAT — see this PR's locked "no P2P networking" scope), so
36
+ the backend trusts this worker's self-reported "running" for a
37
+ customer_node target instead of re-probing an address it could never
38
+ route to (see the backend's own deployment logic for the
39
+ customer-node target case).
40
+ """
41
+ import argparse
42
+ import json
43
+ import re
44
+ import shutil
45
+ import subprocess
46
+ import sys
47
+ import time
48
+ import traceback
49
+ import urllib.error
50
+ import urllib.request
51
+
52
+ POLL_INTERVAL_SECONDS = 3
53
+ HEARTBEAT_INTERVAL_SECONDS = 20
54
+ LOG_TAIL_LINES = 200
55
+ READINESS_TIMEOUT_SECONDS = 300
56
+ READINESS_POLL_SECONDS = 3
57
+ # A plan's own max_runtime_seconds always wins; this is only the backstop
58
+ # for a "job" workload that didn't set one, so a hung diagnostic can't wedge
59
+ # the worker's single-job-at-a-time loop forever (see module docstring).
60
+ DEFAULT_JOB_TIMEOUT_SECONDS = 900
61
+
62
+ # A diagnostic script prints this on its own last line to declare a
63
+ # structured pass/fail verdict distinct from the container's exit code
64
+ # (see report_job_status's `result` field). Anything else is just a normal
65
+ # job with no verdict to report.
66
+ _RESULT_RE = re.compile(r"BADGR_RESULT=(PASS|FAIL)")
67
+ # How often (in poll iterations) to log "still waiting on readiness" during
68
+ # a long model load, so a tail -f doesn't look hung for minutes at a time.
69
+ READINESS_LOG_EVERY_N_POLLS = 5
70
+
71
+ # HOST:CONTAINER or HOST:CONTAINER:ro -- same shape docker itself takes for
72
+ # `-v`. Requires absolute paths on both sides so a relative path (which
73
+ # would resolve against whatever directory the worker daemon happens to be
74
+ # started from, not the customer's intent) fails fast at startup instead of
75
+ # silently mounting the wrong thing.
76
+ _MOUNT_RE = re.compile(r"^(/[^:]+):(/[^:]+)(?::(ro))?$")
77
+
78
+
79
+ def _parse_mount(spec: str) -> tuple[str, str, bool]:
80
+ """Validates and splits one `--mount` value. Raises ValueError with a
81
+ user-facing reason on anything malformed, since a bad mount spec should
82
+ stop the worker from starting rather than fail confusingly on the first
83
+ job."""
84
+ m = _MOUNT_RE.match(spec)
85
+ if not m:
86
+ raise ValueError(
87
+ f"invalid --mount {spec!r}: expected HOST:CONTAINER or HOST:CONTAINER:ro "
88
+ "with absolute paths on both sides"
89
+ )
90
+ host, container, ro = m.group(1), m.group(2), m.group(3)
91
+ return host, container, bool(ro)
92
+
93
+
94
+ def _mount_docker_args(mounts: list[str] | None) -> list[str]:
95
+ """`-v` flags for every configured host mount. See module docstring."""
96
+ args: list[str] = []
97
+ for spec in mounts or []:
98
+ host, container, ro = _parse_mount(spec)
99
+ args += ["-v", f"{host}:{container}" + (":ro" if ro else "")]
100
+ return args
101
+
102
+
103
+ def _detect_gpu_vendor() -> str | None:
104
+ """One-time, read-only vendor probe (mirrors the CLI's own
105
+ gpuDoctor/gpuInfo.js `detectGpus`, kept independent here since the
106
+ worker runs as a separate long-lived process and can't import JS).
107
+ `badgr node connect` already told the backend which vendor it saw at
108
+ connect time, but the worker itself needs a local answer too, to pick
109
+ the right `docker run` device flags per job — see module docstring.
110
+ NVIDIA is checked first only because that's the incumbent path; a
111
+ machine only ever has one or the other. Returns "nvidia", "amd", or
112
+ None (no GPU tooling found — jobs will still be attempted, docker run
113
+ just gets no device flags and will fail visibly instead of silently
114
+ picking a vendor that isn't there)."""
115
+ if shutil.which("nvidia-smi"):
116
+ return "nvidia"
117
+ if shutil.which("rocm-smi"):
118
+ return "amd"
119
+ return None
120
+
121
+
122
+ def _gpu_docker_args(gpu_vendor: str | None) -> list[str]:
123
+ """GPU passthrough flags for `docker run`, per vendor. See
124
+ module docstring."""
125
+ if gpu_vendor == "amd":
126
+ return [
127
+ "--device=/dev/kfd", "--device=/dev/dri",
128
+ "--group-add", "video", "--group-add", "render",
129
+ "--security-opt", "seccomp=unconfined",
130
+ ]
131
+ # Default to the NVIDIA Container Toolkit flag -- matches this worker's
132
+ # pre-ROCm behavior when vendor detection itself fails to find either
133
+ # tool (rare: connect already required one to register the node).
134
+ return ["--gpus", "all"]
135
+
136
+
137
+ def _log(level: str, msg: str) -> None:
138
+ ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
139
+ stream = sys.stderr if level in ("WARN", "ERROR") else sys.stdout
140
+ print(f"{ts}Z [badgr-node] [{level}] {msg}", file=stream, flush=True)
141
+
142
+
143
+ def _api(base_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:
144
+ url = f"{base_url}{path}"
145
+ data = json.dumps(body).encode("utf-8") if body is not None else None
146
+ req = urllib.request.Request(url, data=data, method=method, headers={
147
+ "Content-Type": "application/json",
148
+ "Authorization": f"Bearer {api_key}",
149
+ })
150
+ _log("DEBUG", f"-> {method} {path}" + (f" body={body}" if body else ""))
151
+ try:
152
+ with urllib.request.urlopen(req, timeout=15) as resp:
153
+ raw = resp.read()
154
+ _log("DEBUG", f"<- {method} {path} HTTP {resp.status}")
155
+ return json.loads(raw) if raw else {}
156
+ except urllib.error.HTTPError as e:
157
+ detail = e.read().decode("utf-8", "replace")
158
+ _log("ERROR", f"<- {method} {path} HTTP {e.code}: {detail}")
159
+ raise RuntimeError(f"{method} {path} -> HTTP {e.code}: {detail}") from e
160
+
161
+
162
+ def _http_ok(method: str, url: str, body: dict | None = None, timeout: float = 5.0) -> tuple[bool, str]:
163
+ data = json.dumps(body).encode("utf-8") if body is not None else None
164
+ req = urllib.request.Request(url, data=data, method=method,
165
+ headers={"Content-Type": "application/json"} if data else {})
166
+ try:
167
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
168
+ return resp.status == 200, ""
169
+ except urllib.error.HTTPError as e:
170
+ return False, f"HTTP {e.code}"
171
+ except Exception as exc: # noqa: BLE001 — a probe failure is evidence ("not ready yet"), not a crash
172
+ return False, str(exc)
173
+
174
+
175
+ def _verify_endpoint_locally(port: int, health_path: str, model: str | None) -> tuple[bool, str]:
176
+ """Same two checks the backend's own readiness/inference verification
177
+ logic makes (same paths, same minimal completion payload) — run locally
178
+ since this is the only place that can actually reach the container.
179
+ See module docstring."""
180
+ base = f"http://127.0.0.1:{port}"
181
+ _log("INFO", f"readiness: polling GET {base}{health_path} (timeout {READINESS_TIMEOUT_SECONDS}s)")
182
+ deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
183
+ ready, detail = False, "readiness_timeout"
184
+ attempt = 0
185
+ while time.monotonic() < deadline:
186
+ attempt += 1
187
+ ready, detail = _http_ok("GET", base + health_path)
188
+ if ready:
189
+ _log("INFO", f"readiness: OK after {attempt} attempt(s)")
190
+ break
191
+ if attempt % READINESS_LOG_EVERY_N_POLLS == 0:
192
+ _log("INFO", f"readiness: still waiting (attempt {attempt}, last result: {detail})")
193
+ time.sleep(READINESS_POLL_SECONDS)
194
+ if not ready:
195
+ _log("ERROR", f"readiness: failed after {attempt} attempt(s): {detail}")
196
+ return False, f"readiness check failed: {detail}"
197
+ if not model:
198
+ _log("INFO", "inference verification: skipped (no model on plan)")
199
+ return True, ""
200
+ _log("INFO", f"inference verification: POST {base}/v1/completions model={model}")
201
+ ok, detail = _http_ok("POST", base + "/v1/completions", {"model": model, "prompt": "hi", "max_tokens": 1}, timeout=30.0)
202
+ if not ok:
203
+ _log("ERROR", f"inference verification: failed: {detail}")
204
+ return False, f"inference verification failed: {detail}"
205
+ _log("INFO", "inference verification: OK — real completion request succeeded")
206
+ return True, ""
207
+
208
+
209
+ def _parse_diagnostic_result(log_tail: str) -> str | None:
210
+ """Pulls the workload's own BADGR_RESULT= verdict out of its log, if it
211
+ printed one. Scans from the end since it's always the last thing a
212
+ diagnostic script emits (see the module's diagnostic scripts under
213
+ scripts/diagnostics/)."""
214
+ for line in reversed(log_tail.splitlines()):
215
+ m = _RESULT_RE.search(line)
216
+ if m:
217
+ return m.group(1)
218
+ return None
219
+
220
+
221
+ def _run_job(base_url: str, api_key: str, node_id: str, job_id: str, plan: dict, gpu_vendor: str | None = None, mounts: list[str] | None = None) -> None:
222
+ image = plan.get("image")
223
+ command = plan.get("command") or []
224
+ env = plan.get("env") or {}
225
+ port = plan.get("port")
226
+ is_endpoint = plan.get("workload_type") == "endpoint"
227
+ health_path = plan.get("health_path") or "/v1/models"
228
+ model = plan.get("model")
229
+ privileged = bool(plan.get("privileged"))
230
+ max_runtime_seconds = plan.get("max_runtime_seconds") or DEFAULT_JOB_TIMEOUT_SECONDS
231
+
232
+ container_name = f"badgr-job-{job_id}"
233
+ docker_cmd = ["docker", "run", *_gpu_docker_args(gpu_vendor), *_mount_docker_args(mounts), "--name", container_name]
234
+ docker_cmd += ["--rm"] if not is_endpoint else ["-d"]
235
+ if privileged:
236
+ # Bounded host diagnostics (NVLink/P2P/FabricManager/dmesg checks)
237
+ # need real host device + syslog access that a plain --gpus all
238
+ # container never gets -- see module docstring's "no P2P networking"
239
+ # note for why this still only ever talks to localhost/the host
240
+ # itself, never anything the backend can't already see logs from.
241
+ docker_cmd += ["--privileged", "--pid=host", "-v", "/dev:/dev", "-v", "/var/log:/var/log:ro"]
242
+ for k, v in env.items():
243
+ docker_cmd += ["-e", f"{k}={v}"]
244
+ if is_endpoint and port:
245
+ docker_cmd += ["-p", f"{port}:{port}"]
246
+ docker_cmd.append(image)
247
+ docker_cmd += [str(c) for c in command]
248
+
249
+ def _report(**kwargs) -> None:
250
+ _log("INFO", f"reporting status={kwargs.get('status')} for job {job_id}")
251
+ try:
252
+ _api(base_url, api_key, "POST", f"/nodes/{node_id}/jobs/{job_id}/status", kwargs)
253
+ except Exception as exc: # noqa: BLE001 — best-effort status reporting must never crash the worker loop
254
+ _log("ERROR", f"status report failed for job {job_id}: {exc}")
255
+
256
+ _log("INFO", f"starting job {job_id} (workload_type={'endpoint' if is_endpoint else 'job'}, image={image})")
257
+ _log("DEBUG", f"job {job_id} docker command: {' '.join(docker_cmd)}")
258
+
259
+ if is_endpoint:
260
+ _run_endpoint_job(job_id, docker_cmd, port, health_path, model, _report)
261
+ return
262
+
263
+ proc = subprocess.Popen(docker_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
264
+ _log("INFO", f"job {job_id} container started, pid={proc.pid}, streaming logs, "
265
+ f"max_runtime_seconds={max_runtime_seconds}")
266
+ deadline = time.monotonic() + max_runtime_seconds
267
+ log_lines: list[str] = []
268
+ timed_out = False
269
+ while proc.poll() is None:
270
+ if time.monotonic() > deadline:
271
+ timed_out = True
272
+ _log("ERROR", f"job {job_id} exceeded max_runtime_seconds={max_runtime_seconds}, killing container")
273
+ # `docker kill` on the named container, not proc.pid -- pid is
274
+ # the `docker run` CLI process, killing it alone would leave the
275
+ # container itself running.
276
+ subprocess.run(["docker", "kill", container_name], capture_output=True)
277
+ proc.wait()
278
+ break
279
+ line = proc.stdout.readline() if proc.stdout else ""
280
+ if line:
281
+ line = line.rstrip("\n")
282
+ log_lines.append(line)
283
+ log_lines = log_lines[-LOG_TAIL_LINES:]
284
+ _log("DEBUG", f"job {job_id} | {line}")
285
+ else:
286
+ time.sleep(0.5)
287
+
288
+ tail = "\n".join(log_lines)
289
+ result = _parse_diagnostic_result(tail)
290
+ if timed_out:
291
+ _log("ERROR", f"job {job_id} timed out after {max_runtime_seconds}s")
292
+ _report(status="error", log_tail=tail, error=f"timed out after {max_runtime_seconds}s", result=result or "FAIL")
293
+ return
294
+
295
+ exit_code = proc.returncode
296
+ if exit_code == 0:
297
+ _log("INFO", f"job {job_id} exited 0" + (f", result={result}" if result else ""))
298
+ _report(status="exited", exit_code=exit_code, log_tail=tail, result=result)
299
+ else:
300
+ _log("ERROR", f"job {job_id} failed (exit {exit_code}); last {len(log_lines)} log line(s) attached to report")
301
+ _report(status="error", exit_code=exit_code, log_tail=tail, error=f"container exited {exit_code}", result=result or "FAIL")
302
+
303
+
304
+ def _run_endpoint_job(job_id: str, docker_cmd: list, port: int | None, health_path: str, model: str | None, report) -> None:
305
+ """`badgr serve --target <node>`: launch detached, then block until the
306
+ workload is actually verified (readiness + real completion request) —
307
+ never report "running" on "container started" or "port open" alone."""
308
+ result = subprocess.run(docker_cmd, capture_output=True, text=True)
309
+ if result.returncode != 0:
310
+ _log("ERROR", f"job {job_id}: docker run failed (exit {result.returncode}): {result.stderr[-2000:]}")
311
+ report(status="error", error=f"docker run failed: {result.stderr[-2000:]}")
312
+ return
313
+ container_id = result.stdout.strip()
314
+ _log("INFO", f"job {job_id}: container started ({container_id[:12]}), port={port}, verifying before reporting running")
315
+ if not port:
316
+ _log("ERROR", f"job {job_id}: endpoint workload has no port to verify")
317
+ report(status="error", error="endpoint workload has no port to verify")
318
+ return
319
+
320
+ ok, detail = _verify_endpoint_locally(port, health_path, model)
321
+ if not ok:
322
+ _log("ERROR", f"job {job_id}: endpoint verification failed: {detail} — run `docker logs {container_id[:12]}` on this machine to debug")
323
+ report(status="error", error=detail)
324
+ return
325
+
326
+ _log("INFO", f"job {job_id}: endpoint verified — model={model or '(no model check)'} health_path={health_path}")
327
+ report(status="running", endpoint_url=f"http://127.0.0.1:{port}", log_tail=f"verified: {detail or 'ok'}")
328
+
329
+
330
+ def main() -> None:
331
+ parser = argparse.ArgumentParser(description="badgr-node — BYO GPU worker daemon")
332
+ parser.add_argument("--node-id", required=True, help="node_… id from `badgr node connect`")
333
+ parser.add_argument("--api-key", required=True, help="badgr CLI API key (same one `badgr login` stored)")
334
+ parser.add_argument("--base-url", default="https://aibadgr.com/v1")
335
+ parser.add_argument(
336
+ "--mount", action="append", default=[], metavar="HOST:CONTAINER[:ro]",
337
+ help="Bind-mount a host path into every job's container (repeatable). "
338
+ "Use for host-bound dependencies (venvs, patched source, model/cache "
339
+ "dirs) this workload's command expects to already exist on disk.",
340
+ )
341
+ args = parser.parse_args()
342
+
343
+ for spec in args.mount:
344
+ _parse_mount(spec) # fail fast at startup on a malformed --mount, not mid-job
345
+
346
+ gpu_vendor = _detect_gpu_vendor()
347
+ _log("INFO", f"worker starting for node_id={args.node_id} against {args.base_url}")
348
+ _log("INFO", f"gpu_vendor={gpu_vendor or 'unknown'} (docker run device flags: {' '.join(_gpu_docker_args(gpu_vendor))})")
349
+ if args.mount:
350
+ _log("INFO", f"host mounts: {', '.join(args.mount)}")
351
+ _log("INFO", f"heartbeat_interval={HEARTBEAT_INTERVAL_SECONDS}s poll_interval={POLL_INTERVAL_SECONDS}s")
352
+ last_heartbeat = 0.0
353
+ while True:
354
+ now = time.time()
355
+ if now - last_heartbeat >= HEARTBEAT_INTERVAL_SECONDS:
356
+ try:
357
+ _api(args.base_url, args.api_key, "POST", f"/nodes/{args.node_id}/heartbeat")
358
+ last_heartbeat = now
359
+ _log("DEBUG", "heartbeat OK")
360
+ except Exception as exc: # noqa: BLE001 — a missed heartbeat must not crash the worker loop
361
+ _log("WARN", f"heartbeat failed (will retry next cycle): {exc}")
362
+
363
+ try:
364
+ job = _api(args.base_url, args.api_key, "GET", f"/nodes/{args.node_id}/jobs/next")
365
+ except Exception as exc: # noqa: BLE001 — a missed poll must not crash the worker loop
366
+ _log("WARN", f"job poll failed (will retry next cycle): {exc}")
367
+ job = {}
368
+
369
+ if job.get("job_id"):
370
+ _log("INFO", f"received job {job['job_id']} from poll")
371
+ try:
372
+ _run_job(args.base_url, args.api_key, args.node_id, job["job_id"], job.get("plan") or {}, gpu_vendor, args.mount)
373
+ except Exception: # noqa: BLE001 — a bug in job handling must not silently kill the worker loop
374
+ _log("ERROR", f"unhandled exception while running job {job['job_id']}:\n{traceback.format_exc()}")
375
+ else:
376
+ time.sleep(POLL_INTERVAL_SECONDS)
377
+
378
+
379
+ if __name__ == "__main__":
380
+ main()