badgr-cli 1.1.6 → 1.1.7

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,326 @@
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
+ A "job" workload runs to completion and reports its real exit code. An
16
+ "endpoint" workload (badgr serve) runs detached and is reported "running"
17
+ only after a real readiness check (GET health_path) and, when a model was
18
+ requested, a real minimal completion request (POST /v1/completions) both
19
+ succeed against the container locally — the same two checks
20
+ the backend's own readiness/inference verification logic makes, same URL
21
+ paths and payload shape, just run from here instead of from the backend. That's not a stylistic choice: Badgr's
22
+ backend cannot reach a customer's own machine directly (outbound-only,
23
+ often behind NAT — see this PR's locked "no P2P networking" scope), so
24
+ the backend trusts this worker's self-reported "running" for a
25
+ customer_node target instead of re-probing an address it could never
26
+ route to (see the backend's own deployment logic for the
27
+ customer-node target case).
28
+ """
29
+ import argparse
30
+ import json
31
+ import re
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import time
36
+ import traceback
37
+ import urllib.error
38
+ import urllib.request
39
+
40
+ POLL_INTERVAL_SECONDS = 3
41
+ HEARTBEAT_INTERVAL_SECONDS = 20
42
+ LOG_TAIL_LINES = 200
43
+ READINESS_TIMEOUT_SECONDS = 300
44
+ READINESS_POLL_SECONDS = 3
45
+ # A plan's own max_runtime_seconds always wins; this is only the backstop
46
+ # for a "job" workload that didn't set one, so a hung diagnostic can't wedge
47
+ # the worker's single-job-at-a-time loop forever (see module docstring).
48
+ DEFAULT_JOB_TIMEOUT_SECONDS = 900
49
+
50
+ # A diagnostic script prints this on its own last line to declare a
51
+ # structured pass/fail verdict distinct from the container's exit code
52
+ # (see report_job_status's `result` field). Anything else is just a normal
53
+ # job with no verdict to report.
54
+ _RESULT_RE = re.compile(r"BADGR_RESULT=(PASS|FAIL)")
55
+ # How often (in poll iterations) to log "still waiting on readiness" during
56
+ # a long model load, so a tail -f doesn't look hung for minutes at a time.
57
+ READINESS_LOG_EVERY_N_POLLS = 5
58
+
59
+
60
+ def _detect_gpu_vendor() -> str | None:
61
+ """One-time, read-only vendor probe (mirrors the CLI's own
62
+ gpuDoctor/gpuInfo.js `detectGpus`, kept independent here since the
63
+ worker runs as a separate long-lived process and can't import JS).
64
+ `badgr node connect` already told the backend which vendor it saw at
65
+ connect time, but the worker itself needs a local answer too, to pick
66
+ the right `docker run` device flags per job — see module docstring.
67
+ NVIDIA is checked first only because that's the incumbent path; a
68
+ machine only ever has one or the other. Returns "nvidia", "amd", or
69
+ None (no GPU tooling found — jobs will still be attempted, docker run
70
+ just gets no device flags and will fail visibly instead of silently
71
+ picking a vendor that isn't there)."""
72
+ if shutil.which("nvidia-smi"):
73
+ return "nvidia"
74
+ if shutil.which("rocm-smi"):
75
+ return "amd"
76
+ return None
77
+
78
+
79
+ def _gpu_docker_args(gpu_vendor: str | None) -> list[str]:
80
+ """GPU passthrough flags for `docker run`, per vendor. See
81
+ module docstring."""
82
+ if gpu_vendor == "amd":
83
+ return [
84
+ "--device=/dev/kfd", "--device=/dev/dri",
85
+ "--group-add", "video", "--group-add", "render",
86
+ "--security-opt", "seccomp=unconfined",
87
+ ]
88
+ # Default to the NVIDIA Container Toolkit flag -- matches this worker's
89
+ # pre-ROCm behavior when vendor detection itself fails to find either
90
+ # tool (rare: connect already required one to register the node).
91
+ return ["--gpus", "all"]
92
+
93
+
94
+ def _log(level: str, msg: str) -> None:
95
+ ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
96
+ stream = sys.stderr if level in ("WARN", "ERROR") else sys.stdout
97
+ print(f"{ts}Z [badgr-node] [{level}] {msg}", file=stream, flush=True)
98
+
99
+
100
+ def _api(base_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:
101
+ url = f"{base_url}{path}"
102
+ data = json.dumps(body).encode("utf-8") if body is not None else None
103
+ req = urllib.request.Request(url, data=data, method=method, headers={
104
+ "Content-Type": "application/json",
105
+ "Authorization": f"Bearer {api_key}",
106
+ })
107
+ _log("DEBUG", f"-> {method} {path}" + (f" body={body}" if body else ""))
108
+ try:
109
+ with urllib.request.urlopen(req, timeout=15) as resp:
110
+ raw = resp.read()
111
+ _log("DEBUG", f"<- {method} {path} HTTP {resp.status}")
112
+ return json.loads(raw) if raw else {}
113
+ except urllib.error.HTTPError as e:
114
+ detail = e.read().decode("utf-8", "replace")
115
+ _log("ERROR", f"<- {method} {path} HTTP {e.code}: {detail}")
116
+ raise RuntimeError(f"{method} {path} -> HTTP {e.code}: {detail}") from e
117
+
118
+
119
+ def _http_ok(method: str, url: str, body: dict | None = None, timeout: float = 5.0) -> tuple[bool, str]:
120
+ data = json.dumps(body).encode("utf-8") if body is not None else None
121
+ req = urllib.request.Request(url, data=data, method=method,
122
+ headers={"Content-Type": "application/json"} if data else {})
123
+ try:
124
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
125
+ return resp.status == 200, ""
126
+ except urllib.error.HTTPError as e:
127
+ return False, f"HTTP {e.code}"
128
+ except Exception as exc: # noqa: BLE001 — a probe failure is evidence ("not ready yet"), not a crash
129
+ return False, str(exc)
130
+
131
+
132
+ def _verify_endpoint_locally(port: int, health_path: str, model: str | None) -> tuple[bool, str]:
133
+ """Same two checks the backend's own readiness/inference verification
134
+ logic makes (same paths, same minimal completion payload) — run locally
135
+ since this is the only place that can actually reach the container.
136
+ See module docstring."""
137
+ base = f"http://127.0.0.1:{port}"
138
+ _log("INFO", f"readiness: polling GET {base}{health_path} (timeout {READINESS_TIMEOUT_SECONDS}s)")
139
+ deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
140
+ ready, detail = False, "readiness_timeout"
141
+ attempt = 0
142
+ while time.monotonic() < deadline:
143
+ attempt += 1
144
+ ready, detail = _http_ok("GET", base + health_path)
145
+ if ready:
146
+ _log("INFO", f"readiness: OK after {attempt} attempt(s)")
147
+ break
148
+ if attempt % READINESS_LOG_EVERY_N_POLLS == 0:
149
+ _log("INFO", f"readiness: still waiting (attempt {attempt}, last result: {detail})")
150
+ time.sleep(READINESS_POLL_SECONDS)
151
+ if not ready:
152
+ _log("ERROR", f"readiness: failed after {attempt} attempt(s): {detail}")
153
+ return False, f"readiness check failed: {detail}"
154
+ if not model:
155
+ _log("INFO", "inference verification: skipped (no model on plan)")
156
+ return True, ""
157
+ _log("INFO", f"inference verification: POST {base}/v1/completions model={model}")
158
+ ok, detail = _http_ok("POST", base + "/v1/completions", {"model": model, "prompt": "hi", "max_tokens": 1}, timeout=30.0)
159
+ if not ok:
160
+ _log("ERROR", f"inference verification: failed: {detail}")
161
+ return False, f"inference verification failed: {detail}"
162
+ _log("INFO", "inference verification: OK — real completion request succeeded")
163
+ return True, ""
164
+
165
+
166
+ def _parse_diagnostic_result(log_tail: str) -> str | None:
167
+ """Pulls the workload's own BADGR_RESULT= verdict out of its log, if it
168
+ printed one. Scans from the end since it's always the last thing a
169
+ diagnostic script emits (see the module's diagnostic scripts under
170
+ scripts/diagnostics/)."""
171
+ for line in reversed(log_tail.splitlines()):
172
+ m = _RESULT_RE.search(line)
173
+ if m:
174
+ return m.group(1)
175
+ return None
176
+
177
+
178
+ def _run_job(base_url: str, api_key: str, node_id: str, job_id: str, plan: dict, gpu_vendor: str | None = None) -> None:
179
+ image = plan.get("image")
180
+ command = plan.get("command") or []
181
+ env = plan.get("env") or {}
182
+ port = plan.get("port")
183
+ is_endpoint = plan.get("workload_type") == "endpoint"
184
+ health_path = plan.get("health_path") or "/v1/models"
185
+ model = plan.get("model")
186
+ privileged = bool(plan.get("privileged"))
187
+ max_runtime_seconds = plan.get("max_runtime_seconds") or DEFAULT_JOB_TIMEOUT_SECONDS
188
+
189
+ container_name = f"badgr-job-{job_id}"
190
+ docker_cmd = ["docker", "run", *_gpu_docker_args(gpu_vendor), "--name", container_name]
191
+ docker_cmd += ["--rm"] if not is_endpoint else ["-d"]
192
+ if privileged:
193
+ # Bounded host diagnostics (NVLink/P2P/FabricManager/dmesg checks)
194
+ # need real host device + syslog access that a plain --gpus all
195
+ # container never gets -- see module docstring's "no P2P networking"
196
+ # note for why this still only ever talks to localhost/the host
197
+ # itself, never anything the backend can't already see logs from.
198
+ docker_cmd += ["--privileged", "--pid=host", "-v", "/dev:/dev", "-v", "/var/log:/var/log:ro"]
199
+ for k, v in env.items():
200
+ docker_cmd += ["-e", f"{k}={v}"]
201
+ if is_endpoint and port:
202
+ docker_cmd += ["-p", f"{port}:{port}"]
203
+ docker_cmd.append(image)
204
+ docker_cmd += [str(c) for c in command]
205
+
206
+ def _report(**kwargs) -> None:
207
+ _log("INFO", f"reporting status={kwargs.get('status')} for job {job_id}")
208
+ try:
209
+ _api(base_url, api_key, "POST", f"/nodes/{node_id}/jobs/{job_id}/status", kwargs)
210
+ except Exception as exc: # noqa: BLE001 — best-effort status reporting must never crash the worker loop
211
+ _log("ERROR", f"status report failed for job {job_id}: {exc}")
212
+
213
+ _log("INFO", f"starting job {job_id} (workload_type={'endpoint' if is_endpoint else 'job'}, image={image})")
214
+ _log("DEBUG", f"job {job_id} docker command: {' '.join(docker_cmd)}")
215
+
216
+ if is_endpoint:
217
+ _run_endpoint_job(job_id, docker_cmd, port, health_path, model, _report)
218
+ return
219
+
220
+ proc = subprocess.Popen(docker_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
221
+ _log("INFO", f"job {job_id} container started, pid={proc.pid}, streaming logs, "
222
+ f"max_runtime_seconds={max_runtime_seconds}")
223
+ deadline = time.monotonic() + max_runtime_seconds
224
+ log_lines: list[str] = []
225
+ timed_out = False
226
+ while proc.poll() is None:
227
+ if time.monotonic() > deadline:
228
+ timed_out = True
229
+ _log("ERROR", f"job {job_id} exceeded max_runtime_seconds={max_runtime_seconds}, killing container")
230
+ # `docker kill` on the named container, not proc.pid -- pid is
231
+ # the `docker run` CLI process, killing it alone would leave the
232
+ # container itself running.
233
+ subprocess.run(["docker", "kill", container_name], capture_output=True)
234
+ proc.wait()
235
+ break
236
+ line = proc.stdout.readline() if proc.stdout else ""
237
+ if line:
238
+ line = line.rstrip("\n")
239
+ log_lines.append(line)
240
+ log_lines = log_lines[-LOG_TAIL_LINES:]
241
+ _log("DEBUG", f"job {job_id} | {line}")
242
+ else:
243
+ time.sleep(0.5)
244
+
245
+ tail = "\n".join(log_lines)
246
+ result = _parse_diagnostic_result(tail)
247
+ if timed_out:
248
+ _log("ERROR", f"job {job_id} timed out after {max_runtime_seconds}s")
249
+ _report(status="error", log_tail=tail, error=f"timed out after {max_runtime_seconds}s", result=result or "FAIL")
250
+ return
251
+
252
+ exit_code = proc.returncode
253
+ if exit_code == 0:
254
+ _log("INFO", f"job {job_id} exited 0" + (f", result={result}" if result else ""))
255
+ _report(status="exited", exit_code=exit_code, log_tail=tail, result=result)
256
+ else:
257
+ _log("ERROR", f"job {job_id} failed (exit {exit_code}); last {len(log_lines)} log line(s) attached to report")
258
+ _report(status="error", exit_code=exit_code, log_tail=tail, error=f"container exited {exit_code}", result=result or "FAIL")
259
+
260
+
261
+ def _run_endpoint_job(job_id: str, docker_cmd: list, port: int | None, health_path: str, model: str | None, report) -> None:
262
+ """`badgr serve --target <node>`: launch detached, then block until the
263
+ workload is actually verified (readiness + real completion request) —
264
+ never report "running" on "container started" or "port open" alone."""
265
+ result = subprocess.run(docker_cmd, capture_output=True, text=True)
266
+ if result.returncode != 0:
267
+ _log("ERROR", f"job {job_id}: docker run failed (exit {result.returncode}): {result.stderr[-2000:]}")
268
+ report(status="error", error=f"docker run failed: {result.stderr[-2000:]}")
269
+ return
270
+ container_id = result.stdout.strip()
271
+ _log("INFO", f"job {job_id}: container started ({container_id[:12]}), port={port}, verifying before reporting running")
272
+ if not port:
273
+ _log("ERROR", f"job {job_id}: endpoint workload has no port to verify")
274
+ report(status="error", error="endpoint workload has no port to verify")
275
+ return
276
+
277
+ ok, detail = _verify_endpoint_locally(port, health_path, model)
278
+ if not ok:
279
+ _log("ERROR", f"job {job_id}: endpoint verification failed: {detail} — run `docker logs {container_id[:12]}` on this machine to debug")
280
+ report(status="error", error=detail)
281
+ return
282
+
283
+ _log("INFO", f"job {job_id}: endpoint verified — model={model or '(no model check)'} health_path={health_path}")
284
+ report(status="running", endpoint_url=f"http://127.0.0.1:{port}", log_tail=f"verified: {detail or 'ok'}")
285
+
286
+
287
+ def main() -> None:
288
+ parser = argparse.ArgumentParser(description="badgr-node — BYO GPU worker daemon")
289
+ parser.add_argument("--node-id", required=True, help="node_… id from `badgr node connect`")
290
+ parser.add_argument("--api-key", required=True, help="badgr CLI API key (same one `badgr login` stored)")
291
+ parser.add_argument("--base-url", default="https://aibadgr.com/v1")
292
+ args = parser.parse_args()
293
+
294
+ gpu_vendor = _detect_gpu_vendor()
295
+ _log("INFO", f"worker starting for node_id={args.node_id} against {args.base_url}")
296
+ _log("INFO", f"gpu_vendor={gpu_vendor or 'unknown'} (docker run device flags: {' '.join(_gpu_docker_args(gpu_vendor))})")
297
+ _log("INFO", f"heartbeat_interval={HEARTBEAT_INTERVAL_SECONDS}s poll_interval={POLL_INTERVAL_SECONDS}s")
298
+ last_heartbeat = 0.0
299
+ while True:
300
+ now = time.time()
301
+ if now - last_heartbeat >= HEARTBEAT_INTERVAL_SECONDS:
302
+ try:
303
+ _api(args.base_url, args.api_key, "POST", f"/nodes/{args.node_id}/heartbeat")
304
+ last_heartbeat = now
305
+ _log("DEBUG", "heartbeat OK")
306
+ except Exception as exc: # noqa: BLE001 — a missed heartbeat must not crash the worker loop
307
+ _log("WARN", f"heartbeat failed (will retry next cycle): {exc}")
308
+
309
+ try:
310
+ job = _api(args.base_url, args.api_key, "GET", f"/nodes/{args.node_id}/jobs/next")
311
+ except Exception as exc: # noqa: BLE001 — a missed poll must not crash the worker loop
312
+ _log("WARN", f"job poll failed (will retry next cycle): {exc}")
313
+ job = {}
314
+
315
+ if job.get("job_id"):
316
+ _log("INFO", f"received job {job['job_id']} from poll")
317
+ try:
318
+ _run_job(args.base_url, args.api_key, args.node_id, job["job_id"], job.get("plan") or {}, gpu_vendor)
319
+ except Exception: # noqa: BLE001 — a bug in job handling must not silently kill the worker loop
320
+ _log("ERROR", f"unhandled exception while running job {job['job_id']}:\n{traceback.format_exc()}")
321
+ else:
322
+ time.sleep(POLL_INTERVAL_SECONDS)
323
+
324
+
325
+ if __name__ == "__main__":
326
+ main()