poly-hammer-worker 0.1.1.dev8__tar.gz → 0.1.1.dev10__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poly-hammer-worker
3
- Version: 0.1.1.dev8
3
+ Version: 0.1.1.dev10
4
4
  Summary: Poly Hammer self-hosted GPU worker agent
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
@@ -37,15 +37,6 @@ class DockerRunError(Exception):
37
37
  super().__init__(f"Container exited with code {exit_code}: {stderr}")
38
38
 
39
39
 
40
- def resolve_image_tag(docker_image: str, cuda_version: str) -> str:
41
- """Build the fully-qualified image reference.
42
-
43
- The backend stores bare image names (e.g. ``ph-hy-motion``).
44
- This appends the CUDA version tag matching the local GPU.
45
- """
46
- return f"{docker_image}:cuda{cuda_version}"
47
-
48
-
49
40
  class InsufficientDiskError(Exception):
50
41
  """Raised when there isn't enough disk space to pull an image."""
51
42
 
@@ -300,6 +291,7 @@ def run_inference_container(
300
291
  job_payload: dict,
301
292
  on_progress: Callable[[float], None] | None = None,
302
293
  on_status: Callable[[str], None] | None = None,
294
+ on_log: Callable[[str], None] | None = None,
303
295
  ) -> dict:
304
296
  """Run an inference container and return the result dict.
305
297
 
@@ -315,6 +307,8 @@ def run_inference_container(
315
307
  Called with progress float (0.0–1.0) as the container reports progress.
316
308
  on_status:
317
309
  Called with human-readable status strings (pulling, running, etc.).
310
+ on_log:
311
+ Called with each line of container output (both stdout and stderr).
318
312
 
319
313
  Returns
320
314
  -------
@@ -324,14 +318,12 @@ def run_inference_container(
324
318
  ------
325
319
  DockerRunError — If the container exits non-zero or emits an error message.
326
320
  """
327
- stdin_bytes = json.dumps(job_payload).encode()
328
-
329
321
  if on_status:
330
322
  on_status(f"Creating container from {image_ref}")
331
323
 
332
324
  container = client.containers.create(
333
325
  image_ref,
334
- stdin_open=True,
326
+ environment={"JOB_PAYLOAD": json.dumps(job_payload)},
335
327
  detach=True,
336
328
  device_requests=[
337
329
  docker.types.DeviceRequest(count=-1, capabilities=[["gpu"]]),
@@ -344,26 +336,26 @@ def run_inference_container(
344
336
  try:
345
337
  container.start()
346
338
 
347
- # Attach and pipe job JSON into stdin
348
- sock = container.attach_socket(params={"stdin": 1, "stream": 1})
349
- sock._sock.sendall(stdin_bytes)
350
- sock._sock.shutdown(1) # EOF on stdin
351
- sock.close()
352
-
353
339
  if on_status:
354
340
  on_status("Inference running …")
355
341
 
356
- # Stream stdout for JSON-line progress messages
342
+ # Stream both stdout and stderr for JSON-line progress + container logs
343
+ stderr_lines: list[str] = []
357
344
  for raw_line in container.logs(
358
- stream=True, follow=True, stdout=True, stderr=False
345
+ stream=True, follow=True, stdout=True, stderr=True
359
346
  ):
360
347
  line = raw_line.decode("utf-8", errors="replace").strip()
361
348
  if not line:
362
349
  continue
350
+
351
+ if on_log:
352
+ on_log(line)
353
+
363
354
  try:
364
355
  msg = json.loads(line)
365
356
  except json.JSONDecodeError:
366
- logger.debug("Non-JSON output: %s", line[:200])
357
+ # Non-JSON output is stderr or unstructured stdout
358
+ stderr_lines.append(line)
367
359
  continue
368
360
 
369
361
  msg_type = msg.get("type")
@@ -384,10 +376,8 @@ def run_inference_container(
384
376
  raise DockerRunError(exit_code, error_msg)
385
377
 
386
378
  if exit_code != 0:
387
- stderr_logs = container.logs(stdout=False, stderr=True).decode(
388
- "utf-8", errors="replace"
389
- )[-2000:]
390
- raise DockerRunError(exit_code, stderr_logs)
379
+ stderr_tail = "\n".join(stderr_lines)[-2000:]
380
+ raise DockerRunError(exit_code, stderr_tail)
391
381
 
392
382
  if result_msg is None:
393
383
  raise DockerRunError(
@@ -10,7 +10,12 @@ Required labels (image is skipped if any are missing):
10
10
  - ai.poly-hammer.portal-compatible = "true"
11
11
  - ai.poly-hammer.service-id
12
12
  - ai.poly-hammer.model-ids (comma-separated model IDs)
13
- - ai.poly-hammer.cuda-version
13
+
14
+ Optional labels:
15
+ - ai.poly-hammer.min-cuda-version Minimum host CUDA version required.
16
+ When set **and** *host_cuda_version* is passed to
17
+ :func:`discover_local_images`, images whose minimum exceeds the
18
+ host capability are skipped with a warning.
14
19
 
15
20
  Per-model labels (optional, looked up for each model ID):
16
21
  - ai.poly-hammer.model.<id>.name
@@ -32,17 +37,22 @@ _REQUIRED_LABELS = frozenset(
32
37
  "portal-compatible",
33
38
  "service-id",
34
39
  "model-ids",
35
- "cuda-version",
36
40
  }
37
41
  )
38
42
 
39
43
 
40
- def discover_local_images(client: docker.DockerClient) -> list[dict[str, Any]]:
44
+ def discover_local_images(
45
+ client: docker.DockerClient,
46
+ host_cuda_version: str | None = None,
47
+ ) -> list[dict[str, Any]]:
41
48
  """Scan local Docker images for portal-compatible OCI labels.
42
49
 
43
50
  Returns a list of dicts matching the ``WorkerImageInfo`` schema — one
44
51
  entry per Docker image. Each entry contains a ``models`` list with
45
52
  per-model metadata (name, hardware requirements).
53
+
54
+ If *host_cuda_version* is provided (e.g. ``"13.1"``), images whose
55
+ ``ai.poly-hammer.min-cuda-version`` label exceeds it are skipped.
46
56
  """
47
57
  results: list[dict[str, Any]] = []
48
58
 
@@ -78,7 +88,18 @@ def discover_local_images(client: docker.DockerClient) -> list[dict[str, Any]]:
78
88
 
79
89
  image_ref = _best_tag(image)
80
90
  service_id = ph_labels["service-id"]
81
- cuda_version = ph_labels["cuda-version"]
91
+ min_cuda_version: str | None = ph_labels.get("min-cuda-version")
92
+
93
+ # Host compatibility check: skip images that need a newer CUDA
94
+ if min_cuda_version and host_cuda_version:
95
+ if _parse_version(min_cuda_version) > _parse_version(host_cuda_version):
96
+ logger.warning(
97
+ "Image %s requires CUDA >= %s but host has %s — skipping",
98
+ image_ref,
99
+ min_cuda_version,
100
+ host_cuda_version,
101
+ )
102
+ continue
82
103
 
83
104
  # Image size in GB (Docker reports bytes)
84
105
  image_size_gb = round(image.attrs.get("Size", 0) / (1024**3), 2)
@@ -106,7 +127,7 @@ def discover_local_images(client: docker.DockerClient) -> list[dict[str, Any]]:
106
127
  {
107
128
  "image_ref": image_ref,
108
129
  "service_id": service_id,
109
- "cuda_version": cuda_version,
130
+ "min_cuda_version": min_cuda_version,
110
131
  "image_size_gb": image_size_gb,
111
132
  "models": models,
112
133
  }
@@ -136,3 +157,29 @@ def _parse_int(value: str) -> int:
136
157
  return int(value)
137
158
  except (ValueError, TypeError):
138
159
  return 0
160
+
161
+
162
+ def _parse_version(version: str) -> tuple[int, ...]:
163
+ """Parse a dotted version string into a tuple of ints for comparison."""
164
+ try:
165
+ return tuple(int(p) for p in version.split("."))
166
+ except (ValueError, TypeError):
167
+ return (0,)
168
+
169
+
170
+ def resolve_image_for_model(
171
+ available_images: list[dict[str, Any]], model_id: str
172
+ ) -> str | None:
173
+ """Find the local Docker image reference that supports a given model ID.
174
+
175
+ Searches the list returned by :func:`discover_local_images` for the
176
+ first image whose ``models`` list contains the requested *model_id*.
177
+
178
+ Returns the ``image_ref`` (e.g. ``"hy-motion:1.0"``) or ``None`` if
179
+ no matching image is found.
180
+ """
181
+ for img in available_images:
182
+ for model in img.get("models", []):
183
+ if model.get("model_id") == model_id:
184
+ return img.get("image_ref")
185
+ return None
@@ -22,10 +22,10 @@ from poly_hammer_worker.docker_runner import (
22
22
  InsufficientDiskError,
23
23
  ensure_image,
24
24
  get_free_disk_gb,
25
- resolve_image_tag,
26
25
  run_inference_container,
27
26
  )
28
27
  from poly_hammer_worker.hardware import detect_hardware
28
+ from poly_hammer_worker.image_discovery import resolve_image_for_model
29
29
 
30
30
  logger = logging.getLogger(__name__)
31
31
 
@@ -87,7 +87,9 @@ def run_worker_loop(
87
87
  docker_client = docker.from_env()
88
88
 
89
89
  # ---- Image discovery (initial) -------------------------------------------
90
- available_images = discover_local_images(docker_client)
90
+ available_images = discover_local_images(
91
+ docker_client, host_cuda_version=cuda_version
92
+ )
91
93
  if available_images:
92
94
  total_models = sum(len(img["models"]) for img in available_images)
93
95
  console.print(
@@ -132,7 +134,9 @@ def run_worker_loop(
132
134
  nonlocal _last_disk_check, available_images
133
135
  while not shutdown.is_set():
134
136
  # Re-discover images on each heartbeat (cheap — Docker API list)
135
- available_images = discover_local_images(docker_client)
137
+ available_images = discover_local_images(
138
+ docker_client, host_cuda_version=cuda_version
139
+ )
136
140
 
137
141
  # Throttled disk space check
138
142
  now = time.monotonic()
@@ -217,7 +221,7 @@ def run_worker_loop(
217
221
  portal=portal,
218
222
  docker_client=docker_client,
219
223
  job=job,
220
- cuda_version=cuda_version,
224
+ available_images=available_images,
221
225
  auto_cleanup=auto_cleanup,
222
226
  registry_url=registry_url,
223
227
  console=console,
@@ -234,21 +238,26 @@ def _execute_job(
234
238
  portal: PortalClient,
235
239
  docker_client: docker.DockerClient,
236
240
  job: dict,
237
- cuda_version: str,
241
+ available_images: list[dict],
238
242
  console: Console,
239
243
  auto_cleanup: bool = False,
240
244
  registry_url: str | None = None,
241
245
  ) -> None:
242
246
  """Ensure image (local-first, optional registry pull), run container, report outcome."""
243
247
  job_id = str(job["job_id"])
244
- docker_image = job.get("docker_image", "")
248
+ model_id = job.get("model_id", "")
245
249
 
246
- if not docker_image:
247
- portal.fail_job(job_id, "Job missing docker_image field", "NO_IMAGE")
248
- console.print("[red] Job missing docker_image — skipped[/red]")
250
+ image_ref = resolve_image_for_model(available_images, model_id)
251
+ if not image_ref:
252
+ portal.fail_job(
253
+ job_id,
254
+ f"No local Docker image found for model '{model_id}'. "
255
+ "Build the appropriate image and restart the worker.",
256
+ "NO_IMAGE",
257
+ )
258
+ console.print(f"[red] No local image for model '{model_id}' — skipped[/red]")
249
259
  return
250
260
 
251
- image_ref = resolve_image_tag(docker_image, cuda_version)
252
261
  start_time = time.monotonic()
253
262
 
254
263
  try:
@@ -262,12 +271,19 @@ def _execute_job(
262
271
  )
263
272
 
264
273
  # ---- Run container ----------------------------------------------------
274
+ container_logger = logging.getLogger(f"{__name__}.container")
275
+
276
+ def _on_log(line: str) -> None:
277
+ container_logger.info(line)
278
+ console.print(f" [dim]│ {line}[/dim]", highlight=False)
279
+
265
280
  result = run_inference_container(
266
281
  docker_client,
267
282
  image_ref,
268
283
  job_payload=job,
269
284
  on_progress=lambda p: _on_progress(portal, job_id, p, console),
270
285
  on_status=lambda s: console.print(f" [dim]{s}[/dim]"),
286
+ on_log=_on_log,
271
287
  )
272
288
 
273
289
  elapsed = time.monotonic() - start_time
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "poly-hammer-worker"
3
- version = "0.1.1.dev8"
3
+ version = "0.1.1.dev10"
4
4
  description = "Poly Hammer self-hosted GPU worker agent"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"