flashnode 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {flashnode-0.3.1/flashnode.egg-info → flashnode-0.3.2}/PKG-INFO +23 -2
  2. flashnode-0.3.1/PKG-INFO → flashnode-0.3.2/README.md +21 -17
  3. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/cli.py +68 -3
  4. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/doctor.py +93 -6
  5. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/argv_runner.py +4 -0
  6. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/docker_runner.py +5 -0
  7. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/hardening.py +33 -0
  8. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/loop.py +101 -1
  9. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/inventory/capabilities.py +17 -1
  10. flashnode-0.3.2/flashnode/inventory/gpu.py +155 -0
  11. flashnode-0.3.2/flashnode/status.py +103 -0
  12. flashnode-0.3.1/README.md → flashnode-0.3.2/flashnode.egg-info/PKG-INFO +38 -0
  13. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/SOURCES.txt +7 -1
  14. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/requires.txt +1 -1
  15. {flashnode-0.3.1 → flashnode-0.3.2}/pyproject.toml +11 -2
  16. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_capabilities.py +26 -0
  17. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_doctor.py +122 -2
  18. flashnode-0.3.2/tests/test_gpu_probe.py +176 -0
  19. flashnode-0.3.2/tests/test_hardening.py +203 -0
  20. flashnode-0.3.2/tests/test_loop_counters.py +223 -0
  21. flashnode-0.3.2/tests/test_status.py +125 -0
  22. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_work_gate.py +25 -0
  23. flashnode-0.3.2/tests/test_work_status.py +129 -0
  24. flashnode-0.3.1/tests/test_hardening.py +0 -44
  25. {flashnode-0.3.1 → flashnode-0.3.2}/LICENSE +0 -0
  26. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/__init__.py +0 -0
  27. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/__init__.py +0 -0
  28. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/daemon.py +0 -0
  29. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/kube.py +0 -0
  30. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/artifacts/__init__.py +0 -0
  31. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/benchmark/__init__.py +0 -0
  32. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/config/__init__.py +0 -0
  33. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/config/local_data.py +0 -0
  34. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/__init__.py +0 -0
  35. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/archives.py +0 -0
  36. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/client.py +0 -0
  37. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/images.py +0 -0
  38. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/runner.py +0 -0
  39. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/__init__.py +0 -0
  40. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/credentials.py +0 -0
  41. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/enrol.py +0 -0
  42. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/store.py +0 -0
  43. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/inventory/__init__.py +0 -0
  44. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/telemetry/__init__.py +0 -0
  45. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/dependency_links.txt +0 -0
  46. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/entry_points.txt +0 -0
  47. {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/top_level.txt +0 -0
  48. {flashnode-0.3.1 → flashnode-0.3.2}/setup.cfg +0 -0
  49. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_agent.py +0 -0
  50. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_allowlist_drift.py +0 -0
  51. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_archives.py +0 -0
  52. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_argv_runner.py +0 -0
  53. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_checkpoint_relay.py +0 -0
  54. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_credentials.py +0 -0
  55. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_docker_runner.py +0 -0
  56. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_enrol.py +0 -0
  57. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_executor.py +0 -0
  58. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_hardening_platform.py +0 -0
  59. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_identity.py +0 -0
  60. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_images_allowlist.py +0 -0
  61. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_interfaces.py +0 -0
  62. {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_local_data.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashnode
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
- Requires-Dist: flashruntime<0.5,>=0.4
10
+ Requires-Dist: flashruntime<0.5,>=0.4.1
11
11
  Requires-Dist: psutil>=5.9
12
12
  Requires-Dist: websockets>=12
13
13
  Requires-Dist: cryptography>=42
@@ -65,6 +65,27 @@ image pull — a registry blip should not stop an agent whose images are
65
65
  already cached — and refuses to start if anything fails, because a host that
66
66
  cannot run tasks should not be claiming them.
67
67
 
68
+ ## While it runs
69
+
70
+ On a terminal you get a live status block:
71
+
72
+ ```
73
+ flashnode 0.3.2 · flashml-api.onrender.com · up 2h14m
74
+ running fed-2e2d4d6ab57f · attempt 1 · 38s
75
+ session 12 accepted 0 failed
76
+ heartbeat 2s ago
77
+ ```
78
+
79
+ `waiting · no work queued — this is normal` means exactly that: the pool has
80
+ nothing for you right now, and your machine is fine. Pipe the output
81
+ anywhere, or pass `--log-json`, and you get the machine-readable log instead.
82
+
83
+ If three tasks in a row fail **on your machine**, the agent re-runs its own
84
+ checks. Pass, and the jobs were broken rather than your host, so it carries
85
+ on. Fail, and it stops claiming and tells you what to fix — instead of
86
+ burning a job's retries on a machine that cannot run anything.
87
+ `--max-consecutive-failures 0` turns that off.
88
+
68
89
  ## What it does today
69
90
 
70
91
  ```bash
@@ -1,20 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: flashnode
3
- Version: 0.3.1
4
- Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
5
- License: Apache-2.0
6
- Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
7
- Requires-Python: >=3.10
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: flashruntime<0.5,>=0.4
11
- Requires-Dist: psutil>=5.9
12
- Requires-Dist: websockets>=12
13
- Requires-Dist: cryptography>=42
14
- Provides-Extra: dev
15
- Requires-Dist: pytest; extra == "dev"
16
- Dynamic: license-file
17
-
18
1
  # FlashNode
19
2
 
20
3
  > **The open host agent of the FlashML system.** Install FlashNode on a
@@ -65,6 +48,27 @@ image pull — a registry blip should not stop an agent whose images are
65
48
  already cached — and refuses to start if anything fails, because a host that
66
49
  cannot run tasks should not be claiming them.
67
50
 
51
+ ## While it runs
52
+
53
+ On a terminal you get a live status block:
54
+
55
+ ```
56
+ flashnode 0.3.2 · flashml-api.onrender.com · up 2h14m
57
+ running fed-2e2d4d6ab57f · attempt 1 · 38s
58
+ session 12 accepted 0 failed
59
+ heartbeat 2s ago
60
+ ```
61
+
62
+ `waiting · no work queued — this is normal` means exactly that: the pool has
63
+ nothing for you right now, and your machine is fine. Pipe the output
64
+ anywhere, or pass `--log-json`, and you get the machine-readable log instead.
65
+
66
+ If three tasks in a row fail **on your machine**, the agent re-runs its own
67
+ checks. Pass, and the jobs were broken rather than your host, so it carries
68
+ on. Fail, and it stops claiming and tells you what to fix — instead of
69
+ burning a job's retries on a machine that cannot run anything.
70
+ `--max-consecutive-failures 0` turns that off.
71
+
68
72
  ## What it does today
69
73
 
70
74
  ```bash
@@ -180,6 +180,16 @@ def _work(args: list[str]) -> int:
180
180
  )
181
181
  parser.add_argument("--max-tasks", type=int, default=None)
182
182
  parser.add_argument("--poll-seconds", type=float, default=1.0)
183
+ parser.add_argument(
184
+ "--log-json", action="store_true",
185
+ help="keep the machine-readable JSON log instead of the live status view",
186
+ )
187
+ parser.add_argument(
188
+ "--max-consecutive-failures", type=int,
189
+ default=int(os.environ.get("FLASHNODE_MAX_CONSECUTIVE_FAILURES", "3")),
190
+ help="host-side failures in a row before re-checking this machine and "
191
+ "stopping if it is broken (0 disables)",
192
+ )
183
193
  opts = parser.parse_args(args)
184
194
 
185
195
  runner = None
@@ -214,10 +224,18 @@ def _work(args: list[str]) -> int:
214
224
  # someone else's machine, and a transient registry blip must not
215
225
  # stop one whose images are already cached. `flashnode doctor` does
216
226
  # the pull.
217
- from flashnode.doctor import format_results, run_checks
227
+ #
228
+ # The gate reads doctor's own NON_BLOCKING_STATUSES rather than
229
+ # testing `!= "ok"` here. Not every check is a gate: the GPU check is
230
+ # informational, because most volunteers have no GPU and blocking on
231
+ # it would lock the entire existing fleet out of CPU work the moment
232
+ # they upgraded. Keeping the predicate in one place is what stops
233
+ # `flashnode work` and `flashnode doctor` disagreeing about which
234
+ # verdicts are fatal.
235
+ from flashnode.doctor import NON_BLOCKING_STATUSES, format_results, run_checks
218
236
 
219
237
  results = run_checks(pull=False)
220
- if any(r.status != "ok" for r in results):
238
+ if any(r.status not in NON_BLOCKING_STATUSES for r in results):
221
239
  print(
222
240
  f"flashnode work: this machine cannot run tasks with "
223
241
  f"--runner {opts.runner}.\n" + format_results(results)
@@ -264,10 +282,29 @@ def _work(args: list[str]) -> int:
264
282
  module_capable=(opts.runner != "argv"),
265
283
  )
266
284
  client.register(registration)
285
+ from flashnode.doctor import NON_BLOCKING_STATUSES, run_checks
286
+
287
+ def _blocking_problems():
288
+ """What the loop calls after a streak of host-side failures.
289
+
290
+ Filtered HERE, with the same set the startup gate reads, so the two
291
+ cannot drift — and so `loop.py` never has to import the doctor
292
+ (which would close a loop -> doctor -> executor -> loop cycle).
293
+ The GPU check reports "info" and never fails; a loop testing
294
+ `!= "ok"` itself would quarantine every CPU-only volunteer.
295
+
296
+ pull=False for the same reason the startup gate uses it: a registry
297
+ blip must not stop an agent whose images are already cached.
298
+ """
299
+ return [r for r in run_checks(pull=False)
300
+ if r.status not in NON_BLOCKING_STATUSES]
301
+
267
302
  loop = ExecutorLoop(
268
303
  client, node_id, runner=runner,
269
304
  poll_seconds=opts.poll_seconds, workdir_base=workdir_base,
270
305
  registration=registration, # survives coordinator restarts
306
+ health_check=_blocking_problems,
307
+ max_consecutive_failures=opts.max_consecutive_failures,
271
308
  )
272
309
 
273
310
  def _stop(signum, frame): # noqa: ARG001
@@ -275,7 +312,35 @@ def _work(args: list[str]) -> int:
275
312
 
276
313
  signal.signal(signal.SIGTERM, _stop)
277
314
  signal.signal(signal.SIGINT, _stop)
278
- accepted = loop.run(max_tasks=opts.max_tasks)
315
+ view = None
316
+ if sys.stdout.isatty() and not opts.log_json:
317
+ # Two writers redrawing one terminal is unreadable, so the JSON
318
+ # handler goes when the view arrives. --log-json keeps it, and a
319
+ # non-TTY never gets here — ANSI cursor movement into a pipe or a
320
+ # systemd journal is corruption, not output.
321
+ from flashnode.status import StatusView
322
+
323
+ logging.getLogger().handlers.clear()
324
+ view = StatusView(loop, coordinator=opts.coordinator,
325
+ version=__version__, stream=sys.stdout)
326
+ view.start()
327
+ try:
328
+ accepted = loop.run(max_tasks=opts.max_tasks)
329
+ finally:
330
+ if view is not None:
331
+ view.stop()
332
+
333
+ if getattr(loop, "quarantined", False):
334
+ from flashnode.doctor import format_results
335
+
336
+ print(
337
+ "\nflashnode work: stopping — this machine can no longer run "
338
+ "tasks.\n" + format_results(loop.health_report or [])
339
+ + "\n\nFix the above, then `flashnode doctor` to confirm before "
340
+ "restarting.",
341
+ file=sys.stderr,
342
+ )
343
+ return 2
279
344
  print(f"flashnode work: {accepted} task(s) accepted", file=sys.stderr)
280
345
  return 0
281
346
 
@@ -34,12 +34,16 @@ from flashnode.executor.hardening import (
34
34
  _bind_mount_source,
35
35
  harden_args,
36
36
  )
37
+ from flashnode.inventory.gpu import probe_gpus
37
38
 
38
39
  __all__ = [
40
+ "IMAGE_TAG",
41
+ "NON_BLOCKING_STATUSES",
39
42
  "PROBE_IMAGE",
40
43
  "CheckResult",
41
44
  "check_cli_on_path",
42
45
  "check_engine",
46
+ "check_gpus",
43
47
  "check_hardened_run",
44
48
  "check_local_datasets",
45
49
  "check_pull",
@@ -52,15 +56,48 @@ __all__ = [
52
56
  "run_command",
53
57
  ]
54
58
 
59
+ #: The curated-image tag this agent probes. It must equal `IMAGE_TAG` in
60
+ #: `.github/workflows/images.yml` (which builds the images) and in
61
+ #: flashml-cloud's `apps/api/flashml_cloud_api/images.py` (which hands
62
+ #: references to jobs). All three are bumped together; the workflow's
63
+ #: immutability guard refuses to repush an existing tag, so a bump is
64
+ #: mandatory for any image change, never cosmetic.
65
+ #:
66
+ #: Named rather than baked into the string below because the drift is
67
+ #: SILENT: old tags keep pulling forever, so a doctor probing an image two
68
+ #: releases behind the fleet passes every check and certifies a host against
69
+ #: an image no job will ever use. Nothing in either repo tests that the three
70
+ #: agree — the test that would have is gone, deleted on 2026-08-01 when the
71
+ #: image sources moved to this repo. One greppable constant per repo is what
72
+ #: is left holding this together.
73
+ IMAGE_TAG = "2026.08.2"
74
+
55
75
  #: The image every container-level check runs. python-slim, never
56
76
  #: pytorch-cpu: registry auth, TLS and the credential helper are properties
57
77
  #: of the REGISTRY, so the smallest curated image proves the same thing, and
58
78
  #: making a volunteer download gigabytes to learn their helper is missing is
59
- #: a hostile diagnostic. Kept in step with flashml-cloud's published tags.
60
- PROBE_IMAGE = "ghcr.io/zolli-labs/flashml-python-slim:2026.08.1"
79
+ #: a hostile diagnostic. Never pytorch-cuda either, for the same reason
80
+ #: doubled: a GPU host that cannot pull has the same broken registry as
81
+ #: everyone else, and would find out 3 GB later.
82
+ PROBE_IMAGE = f"ghcr.io/zolli-labs/flashml-python-slim:{IMAGE_TAG}"
61
83
 
62
84
  CommandRunner = Callable[..., subprocess.CompletedProcess]
63
85
 
86
+ #: Statuses that do NOT stop a host from working. Everything else does, and
87
+ #: an unrecognised status counts as blocking — this is the certification
88
+ #: predicate, so it fails closed (see `exit_code`).
89
+ #:
90
+ #: The set exists because "info" does. Six of the seven checks answer "can
91
+ #: this machine run a task at all", and blocking is the only correct answer
92
+ #: when one of them fails. The GPU check answers something else — "is the
93
+ #: hardware you think you contributed actually visible" — and most volunteers
94
+ #: have no GPU and must keep taking CPU work. Blocking on that would lock the
95
+ #: entire existing fleet out of the network on upgrade.
96
+ #:
97
+ #: `flashnode work` reads THIS set rather than testing `!= "ok"` itself, so
98
+ #: the gate cannot drift from the exit code.
99
+ NON_BLOCKING_STATUSES = frozenset({"ok", "info"})
100
+
64
101
 
65
102
  @dataclass(frozen=True)
66
103
  class CheckResult:
@@ -71,7 +108,7 @@ class CheckResult:
71
108
  """
72
109
 
73
110
  name: str
74
- status: str # "ok" | "fail" | "skip"
111
+ status: str # "ok" | "fail" | "skip" | "info" (see NON_BLOCKING_STATUSES)
75
112
  detail: str = ""
76
113
  fix: str = ""
77
114
 
@@ -353,6 +390,44 @@ def check_local_datasets(raw: str | None = None) -> CheckResult:
353
390
  return CheckResult(name, "ok", detail=", ".join(sorted(mapping)))
354
391
 
355
392
 
393
+ def check_gpus(probe: Callable[[], list] | None = None) -> CheckResult:
394
+ """What this host will advertise as GPUs — and NOTHING is a fine answer.
395
+
396
+ The only non-gating check in this module (spec §8). It never returns
397
+ "fail": a host with no GPU is the normal host, and the CPU work they
398
+ signed up for is unaffected. It exists for the other case — a host who
399
+ BELIEVES they contributed a GPU learning here that the agent cannot see
400
+ it, rather than wondering why no GPU job ever arrives. That is exactly
401
+ the class of silent failure `doctor` was written for.
402
+
403
+ It reports the same probe `discover()` advertises, not `nvidia-smi`
404
+ directly. A doctor that consults a different source than the agent is a
405
+ doctor that can pass while the agent registers `[]`.
406
+ """
407
+ name = "GPU devices"
408
+ probe = probe or probe_gpus
409
+ try:
410
+ gpus = list(probe())
411
+ except Exception as exc: # noqa: BLE001
412
+ # probe_gpus already promises this cannot happen. If it ever does,
413
+ # a diagnostic that crashes has diagnosed nothing — and crashing on
414
+ # the one check that was never allowed to block would be perverse.
415
+ return CheckResult(name, "info", detail=f"could not probe for GPUs: {exc}")
416
+ if not gpus:
417
+ return CheckResult(
418
+ name, "info",
419
+ detail="no GPU detected — this host will take CPU work only",
420
+ fix="Nothing to do unless you expected a GPU here. If you did: "
421
+ "check `nvidia-smi` runs in this terminal, and install the "
422
+ "NVIDIA Container Toolkit so containers can see the device.",
423
+ )
424
+ lines = [f"{len(gpus)} GPU{'s' if len(gpus) != 1 else ''}"]
425
+ for gpu in gpus:
426
+ memory = f", {gpu.memory_total_mb} MiB" if gpu.memory_total_mb else ""
427
+ lines.append(f"{gpu.index}: {gpu.name or 'unnamed device'}{memory}")
428
+ return CheckResult(name, "ok", detail="\n".join(lines))
429
+
430
+
356
431
  def run_checks(
357
432
  *,
358
433
  pull: bool,
@@ -360,6 +435,7 @@ def run_checks(
360
435
  which: Callable[[str], str | None] | None = None,
361
436
  workdir: Path | None = None,
362
437
  raw_local_data: str | None = None,
438
+ gpu_probe: Callable[[], list] | None = None,
363
439
  ) -> list[CheckResult]:
364
440
  """Run every check, in order, skipping what a prior failure makes
365
441
  meaningless.
@@ -396,13 +472,18 @@ def run_checks(
396
472
  if results[-1].status != "ok":
397
473
  stopped = True
398
474
  results.append(check_local_datasets(raw=raw_local_data))
475
+ # Neither of the last two depends on Docker, so neither is skipped by a
476
+ # failure above: a host debugging their engine should still learn that
477
+ # their local-data label is a typo and that their GPU is invisible.
478
+ results.append(check_gpus(probe=gpu_probe))
399
479
  return results
400
480
 
401
481
 
402
482
  def format_results(results: Sequence[CheckResult]) -> str:
403
483
  lines = []
404
484
  for r in results:
405
- tag = {"ok": "[ok] ", "fail": "[FAIL]", "skip": "[skip]"}[r.status]
485
+ tag = {"ok": "[ok] ", "fail": "[FAIL]", "skip": "[skip]",
486
+ "info": "[info]"}.get(r.status, "[????]")
406
487
  head = r.detail.splitlines()[0] if r.detail else ""
407
488
  lines.append(f" {tag} {r.name:<30} {head}".rstrip())
408
489
  for extra in r.detail.splitlines()[1:]:
@@ -429,8 +510,14 @@ def format_results(results: Sequence[CheckResult]) -> str:
429
510
  def exit_code(results: Sequence[CheckResult]) -> int:
430
511
  """Skipped counts as not-passed. A host whose checks did not run has not
431
512
  been certified, and calling it healthy is the exact failure this command
432
- removes."""
433
- return 0 if all(r.status == "ok" for r in results) else 1
513
+ removes.
514
+
515
+ "info" does not count against a host — see NON_BLOCKING_STATUSES. The
516
+ membership test is deliberately the way round that makes an unknown
517
+ status BLOCK: a check whose verdict this function does not understand
518
+ has not certified anything.
519
+ """
520
+ return 0 if all(r.status in NON_BLOCKING_STATUSES for r in results) else 1
434
521
 
435
522
 
436
523
  def doctor_main(argv: list[str]) -> int:
@@ -76,6 +76,10 @@ class ArgvDockerRunner:
76
76
  # here (before any subprocess) if this host does not lend them
77
77
  # — see hardening.local_data_mounts.
78
78
  local_inputs=payload.get("local_inputs"),
79
+ # The device count the job asked for. Absent for every job
80
+ # that does not ask, and validated in hardening.gpu_flags
81
+ # rather than here, so both runners cannot drift on it.
82
+ gpus=payload.get("gpus"),
79
83
  ),
80
84
  *env_args,
81
85
  image, # argv follows the image, where docker treats it
@@ -80,6 +80,11 @@ class DockerRunner:
80
80
  # See hardening.local_data_mounts: read-only, only what the
81
81
  # payload named, and a refusal if this host does not lend it.
82
82
  local_inputs=payload.get("local_inputs"),
83
+ # The device count the job asked for — see
84
+ # hardening.gpu_flags. Both runners forward this; a runner
85
+ # that quietly does not is a GPU job silently running on the
86
+ # CPU, which still succeeds and so tells nobody.
87
+ gpus=payload.get("gpus"),
83
88
  ),
84
89
  image,
85
90
  "python", "-m", module,
@@ -183,6 +183,30 @@ def local_data_mounts(
183
183
  return args
184
184
 
185
185
 
186
+ def gpu_flags(gpus: object) -> list[str]:
187
+ """`--gpus N`, or nothing at all.
188
+
189
+ Every other flag in this module NARROWS what a container may touch.
190
+ This one widens it — it hands a device on someone else's machine to a
191
+ stranger's code — so it is absent unless the payload asked, and the ask
192
+ has to be a plain positive integer.
193
+
194
+ Anything else emits nothing rather than raising. The coordinator's
195
+ placement gate already refused a task whose `gpus` was not a positive
196
+ int, so a bad value arriving here means gate and payload disagree; this
197
+ is the second lock, not the validation. Two values are worth naming:
198
+
199
+ - `True`. `bool` is an `int` subclass, so a naive check reads it as
200
+ "1 GPU" — and `--gpus True` is not something docker accepts anyway.
201
+ - `"all"`. This is *valid* docker syntax meaning every device on the
202
+ host, which is exactly why an untrusted string must never reach the
203
+ flag. A task asks for a count; it does not get to name a device.
204
+ """
205
+ if isinstance(gpus, bool) or not isinstance(gpus, int) or gpus <= 0:
206
+ return []
207
+ return ["--gpus", str(gpus)]
208
+
209
+
186
210
  def harden_args(
187
211
  workdir: Path,
188
212
  *,
@@ -191,6 +215,7 @@ def harden_args(
191
215
  pids_limit: int = 512,
192
216
  local_inputs: object = None,
193
217
  local_data: dict[str, str] | None = None,
218
+ gpus: object = None,
194
219
  ) -> list[str]:
195
220
  """Docker flags common to every sandboxed task.
196
221
 
@@ -199,6 +224,11 @@ def harden_args(
199
224
  byte-for-byte what they were before the feature existed). `local_data` is
200
225
  the host owner's label→path map; it defaults to their environment, so a
201
226
  runner never has to know where the map comes from.
227
+
228
+ `gpus` is the payload's device count, straight from the wire and
229
+ therefore typed `object` — None for every job that exists today, and
230
+ then, as with `local_inputs`, the flag list is byte-for-byte what it was
231
+ before this argument existed. `doctor.check_hardened_run` passes neither.
202
232
  """
203
233
  return [
204
234
  # the job never reaches the volunteer's LAN or the internet; the
@@ -215,6 +245,9 @@ def harden_args(
215
245
  # bypassable by swapping
216
246
  "--memory", f"{memory_gb}g",
217
247
  "--memory-swap", f"{memory_gb}g",
248
+ # With the other resource caps, and absent entirely for the job that
249
+ # asked for no device — which is every job that exists today.
250
+ *gpu_flags(gpus),
218
251
  "--ulimit", "nofile=1024:1024",
219
252
  "-v", f"{_bind_mount_source(workdir)}:{CONTAINER_WORKDIR}",
220
253
  # After the workdir mount, never before: these land *inside* it, at
@@ -141,6 +141,8 @@ class ExecutorLoop:
141
141
  registration=None, # NodeRegistration; enables re-register after coordinator restart
142
142
  max_unpacked_bytes: int = DEFAULT_MAX_BYTES,
143
143
  max_unpacked_members: int = DEFAULT_MAX_MEMBERS,
144
+ health_check=None,
145
+ max_consecutive_failures: int = 0,
144
146
  ):
145
147
  self.client = client
146
148
  self.node_id = node_id
@@ -159,6 +161,30 @@ class ExecutorLoop:
159
161
  self.stop_event = threading.Event()
160
162
  self.tasks_accepted = 0
161
163
  self._last_node_hb = 0.0
164
+ # Host-facing state. Plain attributes on purpose: flashnode/status.py
165
+ # reads them from a separate thread on a timer, and putting a lock in
166
+ # the claim path to protect a counter would be trading a correctness
167
+ # risk for a cosmetic one.
168
+ self.tasks_failed = 0
169
+ self.consecutive_failures = 0
170
+ self.current_task: str | None = None
171
+ self.current_attempt: int | None = None
172
+ self.current_task_started: float | None = None
173
+ self.quarantined = False
174
+ self.health_report: list | None = None
175
+ # INJECTED, never imported. `doctor.py` imports
176
+ # `flashnode.executor.hardening`, which initialises this package,
177
+ # whose __init__ imports this module — loop -> doctor -> executor ->
178
+ # loop is a cycle that resolves or explodes by import order, which is
179
+ # the worst kind of bug to ship to machines we cannot reach.
180
+ #
181
+ # CONTRACT: health_check() returns the BLOCKING problems; an empty
182
+ # list means healthy. This loop never inspects a `.status`, because
183
+ # the doctor's GPU check reports "info" and never fails — a loop
184
+ # testing `!= "ok"` itself would quarantine every CPU-only volunteer
185
+ # on their third unlucky job.
186
+ self.health_check = health_check
187
+ self.max_consecutive_failures = max_consecutive_failures
162
188
 
163
189
  # -- inputs --------------------------------------------------------------
164
190
 
@@ -212,6 +238,44 @@ class ExecutorLoop:
212
238
  # -- one task ------------------------------------------------------------
213
239
 
214
240
  def execute_one(self, lease: Lease) -> bool:
241
+ """Run one lease, and record what its outcome says about this HOST.
242
+
243
+ Three different things return False here and only one implicates the
244
+ machine:
245
+
246
+ TaskExecutionError — could not run it here -> counts
247
+ LeaseLost — someone else has the work -> does not
248
+ accepted=False — coordinator declined the result -> does not
249
+
250
+ That last one is HTTP 200. Counting it would punish a healthy host
251
+ for losing a commit race — the same trap the contributions ledger hit
252
+ by crediting on 2xx.
253
+ """
254
+ self.current_task = lease.task_id
255
+ self.current_attempt = lease.attempt_number
256
+ self.current_task_started = time.monotonic()
257
+ try:
258
+ accepted = self._execute_inner(lease)
259
+ except TaskExecutionError:
260
+ # _execute_inner already reported fail() and logged the cause.
261
+ self.tasks_failed += 1
262
+ self.consecutive_failures += 1
263
+ return False
264
+ except LeaseLost:
265
+ # _execute_inner normally swallows this and returns False; the
266
+ # belt-and-braces handler makes the contract above true wherever
267
+ # it is raised from, and costs a healthy host nothing.
268
+ return False
269
+ else:
270
+ if accepted:
271
+ self.consecutive_failures = 0
272
+ return accepted
273
+ finally:
274
+ self.current_task = None
275
+ self.current_attempt = None
276
+ self.current_task_started = None
277
+
278
+ def _execute_inner(self, lease: Lease) -> bool:
215
279
  """Run a claimed lease end-to-end. Returns True if the commit was
216
280
  accepted. Never raises for task-level problems — they are reported."""
217
281
  payload = lease.payload
@@ -311,7 +375,10 @@ class ExecutorLoop:
311
375
  self.client.fail(lease.lease_id, str(exc)[:500])
312
376
  except Exception:
313
377
  pass # lease will expire on its own — same outcome, slower
314
- return False
378
+ # Re-raised, not returned: execute_one counts THIS outcome
379
+ # against the host and the other two against nobody. Caught one
380
+ # frame up, so callers still see False.
381
+ raise
315
382
  except LeaseLost:
316
383
  log.warning(_jlog("lease lost", task=lease.task_id))
317
384
  return False
@@ -330,6 +397,37 @@ class ExecutorLoop:
330
397
  self.client.register(self.registration)
331
398
  self._last_node_hb = time.monotonic()
332
399
 
400
+ def _should_stop_volunteering(self) -> bool:
401
+ """After a streak of host-side failures, ask whether it is the HOST.
402
+
403
+ A counter alone would guess. This measures: re-run the same checks
404
+ `flashnode doctor` runs, and let the answer decide.
405
+
406
+ - nothing blocking -> this machine is fine and the JOBS are failing.
407
+ Say so, reset, keep working. A host that stops because of someone
408
+ else's broken job is a host that stops for no reason.
409
+ - blocking problems -> stop claiming. Continuing means burning this
410
+ job's retries on a machine that cannot run anything.
411
+ """
412
+ if self.health_check is None or self.max_consecutive_failures <= 0:
413
+ return False
414
+ if self.consecutive_failures < self.max_consecutive_failures:
415
+ return False
416
+ unhealthy = self.health_check()
417
+ if not unhealthy:
418
+ log.info(_jlog(
419
+ "consecutive task failures, but this host passes its own "
420
+ "checks — the jobs are failing, not the machine",
421
+ failures=self.consecutive_failures))
422
+ self.consecutive_failures = 0
423
+ return False
424
+ self.quarantined = True
425
+ self.health_report = unhealthy
426
+ log.error(_jlog("stopping: this host can no longer run tasks",
427
+ failures=self.consecutive_failures,
428
+ failed_checks=[getattr(r, "name", "?") for r in unhealthy]))
429
+ return True
430
+
333
431
  def run(self, max_tasks: int | None = None, idle_exit: bool = False) -> int:
334
432
  """Claim-and-execute until stopped, `max_tasks` accepted, or — with
335
433
  `idle_exit` — the queue drains (drain mode for tests/one-shot runs).
@@ -356,6 +454,8 @@ class ExecutorLoop:
356
454
  continue
357
455
  log.info(_jlog("claimed", task=lease.task_id, attempt=lease.attempt_number))
358
456
  self.execute_one(lease)
457
+ if self._should_stop_volunteering():
458
+ break
359
459
  return self.tasks_accepted
360
460
 
361
461
 
@@ -14,6 +14,7 @@ import socket
14
14
  import psutil
15
15
 
16
16
  from flashnode.config.local_data import load_local_data
17
+ from flashnode.inventory.gpu import probe_gpus
17
18
  from flashruntime.protocol.v1alpha1 import (
18
19
  NodeCapabilities,
19
20
  NodeEnvironment,
@@ -90,6 +91,17 @@ def discover(node_id: str, kubernetes_node: str,
90
91
 
91
92
  from flashnode import __version__
92
93
 
94
+ # What `nvidia-smi` reports, or nothing. probe_gpus already promises it
95
+ # never raises; this is a second lock on the same door, because the cost
96
+ # of being wrong here is not "no GPU work" but "this agent does not
97
+ # start" — on a machine that was only ever going to run CPU tasks. Unlike
98
+ # local_datasets below, an unreadable GPU is not a misconfiguration the
99
+ # owner must fix; it is the normal state of most hosts.
100
+ try:
101
+ gpus = probe_gpus()
102
+ except Exception:
103
+ gpus = []
104
+
93
105
  return NodeRegistration(
94
106
  node_id=node_id,
95
107
  kubernetes_node=kubernetes_node,
@@ -97,7 +109,11 @@ def discover(node_id: str, kubernetes_node: str,
97
109
  capabilities=NodeCapabilities(
98
110
  cpu_cores=cpu,
99
111
  memory_bytes=memory,
100
- gpus=[], # GPU probing is a documented follow-up; never guess.
112
+ # One entry per device the driver reports; [] on the hosts that
113
+ # have no driver, which is most of them. Placement reads the
114
+ # LENGTH of this list, so an entry we are unsure of is worse
115
+ # than no entry — see inventory/gpu.py.
116
+ gpus=gpus,
101
117
  os=labels.get("kubernetes.io/os", platform.system().lower()),
102
118
  architecture=labels.get("kubernetes.io/arch", arch),
103
119
  ),