labtasker-client 2.4.0__tar.gz → 2.5.0__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 (25) hide show
  1. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/PKG-INFO +4 -2
  2. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/pyproject.toml +5 -2
  3. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/__init__.py +3 -1
  4. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/cli.py +29 -0
  5. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/client.py +21 -0
  6. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/command_worker.py +13 -3
  7. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/execution.py +50 -0
  8. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/models.py +22 -4
  9. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/observations.py +13 -1
  10. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/worker.py +41 -2
  11. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/.gitignore +0 -0
  12. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/LICENSE +0 -0
  13. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/__main__.py +0 -0
  14. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/api.py +0 -0
  15. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/binding.py +0 -0
  16. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/command_template.py +0 -0
  17. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/config.py +0 -0
  18. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/errors.py +0 -0
  19. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/journal.py +0 -0
  20. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/local.py +0 -0
  21. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/paths.py +0 -0
  22. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/py.typed +0 -0
  23. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/tee.py +0 -0
  24. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/types.py +0 -0
  25. {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labtasker-client
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: A small task queue for parallel model inference and evaluation
5
5
  Project-URL: Homepage, https://github.com/luocfprime/labtasker
6
6
  Project-URL: Repository, https://github.com/luocfprime/labtasker.git
@@ -8,10 +8,12 @@ Author-email: lcf <luocfprime@gmail.com>
8
8
  License-Expression: Apache-2.0
9
9
  License-File: LICENSE
10
10
  Requires-Python: >=3.10
11
- Requires-Dist: httpx[socks]<1,>=0.25.2
11
+ Requires-Dist: httpx<1,>=0.25.2
12
12
  Requires-Dist: packaging>=21.3
13
13
  Requires-Dist: pydantic<3,>=2.12; python_version >= '3.14'
14
14
  Requires-Dist: pydantic<3,>=2.5.3; python_version < '3.13'
15
15
  Requires-Dist: pydantic<3,>=2.9; python_version >= '3.13' and python_version < '3.14'
16
16
  Requires-Dist: tomli>=2.0.1; python_version < '3.11'
17
17
  Requires-Dist: typer<1,>=0.19
18
+ Provides-Extra: socks
19
+ Requires-Dist: socksio==1.*; extra == 'socks'
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "labtasker-client"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "A small task queue for parallel model inference and evaluation"
9
9
  requires-python = ">=3.10"
10
10
  license = "Apache-2.0"
11
11
  license-files = ["LICENSE"]
12
12
  authors = [{ name = "lcf", email = "luocfprime@gmail.com" }]
13
13
  dependencies = [
14
- "httpx[socks]>=0.25.2,<1",
14
+ "httpx>=0.25.2,<1",
15
15
  "packaging>=21.3",
16
16
  "pydantic>=2.5.3,<3; python_version < '3.13'",
17
17
  "pydantic>=2.9,<3; python_version >= '3.13' and python_version < '3.14'",
@@ -20,6 +20,9 @@ dependencies = [
20
20
  "typer>=0.19,<1",
21
21
  ]
22
22
 
23
+ [project.optional-dependencies]
24
+ socks = ["socksio==1.*"]
25
+
23
26
  [project.urls]
24
27
  Homepage = "https://github.com/luocfprime/labtasker"
25
28
  Repository = "https://github.com/luocfprime/labtasker.git"
@@ -31,6 +31,7 @@ from labtasker.execution import (
31
31
  cancellation_requested,
32
32
  finish,
33
33
  report_progress,
34
+ report_worker_telemetry,
34
35
  set_force_stop_timeout,
35
36
  task_info,
36
37
  )
@@ -49,7 +50,7 @@ from labtasker.models import (
49
50
  from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
50
51
  from labtasker.worker import loop
51
52
 
52
- __version__ = "2.4.0"
53
+ __version__ = "2.5.0"
53
54
 
54
55
  __all__ = [
55
56
  "APIError",
@@ -89,6 +90,7 @@ __all__ = [
89
90
  "list_workers",
90
91
  "loop",
91
92
  "report_progress",
93
+ "report_worker_telemetry",
92
94
  "requeue_task",
93
95
  "set_force_stop_timeout",
94
96
  "submit_task",
@@ -16,6 +16,7 @@ from labtasker.command_worker import run_command_worker
16
16
  from labtasker.config import resolve_config
17
17
  from labtasker.errors import LabtaskerError
18
18
  from labtasker.execution import report_progress as report_current_progress
19
+ from labtasker.execution import report_worker_telemetry as report_current_worker_telemetry
19
20
  from labtasker.types import TaskOrderField, TaskStatus, TaskUpdate
20
21
  from labtasker.validation import RequestValidationError, validate_grouping, validate_json_object
21
22
 
@@ -126,6 +127,10 @@ def worker_loop(
126
127
  )
127
128
  ),
128
129
  ] = None,
130
+ metadata: Annotated[
131
+ str,
132
+ typer.Option(help="Static Worker metadata as one strict JSON object."),
133
+ ] = "{}",
129
134
  ) -> None:
130
135
  """Claim matching Tasks and execute one child command for each claim.
131
136
 
@@ -146,6 +151,7 @@ def worker_loop(
146
151
  argv.pop(0)
147
152
  if not argv:
148
153
  raise typer.BadParameter("COMMAND is required after --")
154
+ worker_metadata = _json_object(metadata, option="--metadata")
149
155
  try:
150
156
  run_command_worker(
151
157
  argv,
@@ -154,6 +160,7 @@ def worker_loop(
154
160
  idle_timeout=idle_timeout,
155
161
  max_consecutive_failures=max_consecutive_failures,
156
162
  force_stop_timeout=force_stop_timeout,
163
+ metadata=worker_metadata,
157
164
  )
158
165
  except (TemplateSyntaxError, RequestValidationError) as error:
159
166
  raise typer.BadParameter(str(error)) from error
@@ -465,6 +472,28 @@ def worker_count(
465
472
  _write_json({"count": result} if isinstance(result, int) else result)
466
473
 
467
474
 
475
+ @worker_app.command("telemetry")
476
+ def worker_telemetry_report(
477
+ data: Annotated[
478
+ str,
479
+ typer.Option(help="Latest Worker telemetry as one strict JSON object."),
480
+ ],
481
+ ) -> None:
482
+ """Replace telemetry for the current Worker invocation.
483
+
484
+ This command is available inside a command launched by ``labtasker loop``.
485
+ It performs one best-effort synchronous report and prints whether the Server
486
+ accepted it.
487
+ """
488
+ try:
489
+ telemetry = _json_object(data, option="--data")
490
+ reported = _invoke(lambda: report_current_worker_telemetry(telemetry))
491
+ except RuntimeError as error:
492
+ typer.echo(str(error), err=True)
493
+ raise typer.Exit(1) from error
494
+ _write_json({"reported": reported})
495
+
496
+
468
497
  def _count_options(
469
498
  group_by: list[str] | None, allowed: set[str], limit: int | None, cursor: str | None
470
499
  ) -> dict[str, Any]:
@@ -607,6 +607,27 @@ class Client:
607
607
  parser=lambda response: _parse_none(response, {204}),
608
608
  )
609
609
 
610
+ def _report_worker_telemetry(
611
+ self,
612
+ *,
613
+ worker_id: str,
614
+ telemetry: dict[str, JSONValue],
615
+ queue: str | None = None,
616
+ ) -> None:
617
+ self._ensure_open()
618
+ queue_name = self._queue(queue)
619
+ normalized_telemetry = validate_json_object(telemetry, field="telemetry")
620
+ from labtasker.validation import validate_worker_id
621
+
622
+ normalized_worker_id = validate_worker_id(worker_id)
623
+ self._call(
624
+ operation="report_worker_telemetry",
625
+ method="POST",
626
+ path=f"queues/{queue_name}/workers/{normalized_worker_id}/telemetry",
627
+ json={"telemetry": normalized_telemetry},
628
+ parser=lambda response: _parse_none(response, {204}),
629
+ )
630
+
610
631
  def _fail(
611
632
  self,
612
633
  *,
@@ -27,7 +27,7 @@ from labtasker.models import ClaimResponse
27
27
  from labtasker.observations import ObservationReporter
28
28
  from labtasker.tee import configure_worker_logger
29
29
  from labtasker.types import JSONValue
30
- from labtasker.validation import validate_identifier
30
+ from labtasker.validation import validate_identifier, validate_json_object
31
31
  from labtasker.worker import (
32
32
  POLL_INTERVAL_SECONDS,
33
33
  Heartbeat,
@@ -57,19 +57,25 @@ def run_command_worker(
57
57
  idle_timeout: float = 300.0,
58
58
  force_stop_timeout: float | None = None,
59
59
  max_consecutive_failures: int = 5,
60
+ metadata: dict[str, JSONValue] | None = None,
60
61
  ) -> None:
61
62
  guard = _FailureGuard(max_consecutive_failures)
62
63
  templates = compile_argv(argv)
63
64
  normalized_route = validate_identifier(route, field="route")
64
65
  normalized_idle_timeout = _validate_idle_timeout(idle_timeout)
65
66
  normalized_force_stop_timeout = _validate_force_stop_timeout(force_stop_timeout)
67
+ normalized_metadata = validate_json_object(
68
+ {} if metadata is None else metadata, field="metadata"
69
+ )
66
70
  _guard_command_worker_platform()
67
71
  _guard_worker_topology()
68
72
  configure_worker_logger()
69
73
  with Client(queue=queue) as client:
70
74
  queue_name = client.configuration.queue
71
75
  _preflight(client, queue_name)
72
- with ObservationReporter(client.configuration, normalized_route) as observer:
76
+ with ObservationReporter(
77
+ client.configuration, normalized_route, normalized_metadata
78
+ ) as observer:
73
79
  idle_deadline: float | None = None
74
80
  while True:
75
81
  claim = client._claim(
@@ -102,6 +108,7 @@ def run_command_worker(
102
108
  queue=queue_name,
103
109
  route=normalized_route,
104
110
  force_stop_timeout=normalized_force_stop_timeout,
111
+ worker_id=observer.id,
105
112
  )
106
113
 
107
114
  guard.observe(result, claim.task.id)
@@ -124,6 +131,7 @@ def _run_command_claim(
124
131
  queue: str,
125
132
  route: str,
126
133
  force_stop_timeout: float | None,
134
+ worker_id: str,
127
135
  ) -> _ExecutionResult:
128
136
  try:
129
137
  journal = LocalRunJournal.create(
@@ -153,7 +161,7 @@ def _run_command_claim(
153
161
  return _report_command_failure(
154
162
  client, journal, claim, queue, "TaskBindingError", str(error), control=control
155
163
  )
156
- environment = _command_environment(client, claim, journal, queue, route)
164
+ environment = _command_environment(client, claim, journal, queue, route, worker_id)
157
165
  try:
158
166
  if _interactive_terminal():
159
167
  process = _run_pty(
@@ -489,6 +497,7 @@ def _command_environment(
489
497
  journal: LocalRunJournal,
490
498
  queue: str,
491
499
  route: str,
500
+ worker_id: str,
492
501
  ) -> dict[str, str]:
493
502
  environment = dict(os.environ)
494
503
  environment.update(
@@ -498,6 +507,7 @@ def _command_environment(
498
507
  "LABTASKER_RUN_ID": claim.run_id,
499
508
  "LABTASKER_ROUTE": route,
500
509
  "LABTASKER_RUN_DIR": str(journal.run_dir),
510
+ "LABTASKER_WORKER_ID": worker_id,
501
511
  }
502
512
  )
503
513
  configuration = client.configuration
@@ -18,6 +18,7 @@ from labtasker.validation import RequestValidationError, validate_json_object
18
18
 
19
19
  CompletionReporter = Callable[[dict[str, JSONValue]], bool]
20
20
  ProgressReporter = Callable[[dict[str, JSONValue]], bool]
21
+ WorkerTelemetryReporter = Callable[[dict[str, JSONValue]], bool]
21
22
  ContextKind = Literal["python", "command"]
22
23
 
23
24
 
@@ -130,6 +131,7 @@ class ExecutionContext:
130
131
  reporter: CompletionReporter,
131
132
  control: RunControl | None,
132
133
  progress_reporter: ProgressReporter | None = None,
134
+ worker_telemetry_reporter: WorkerTelemetryReporter | None = None,
133
135
  ) -> None:
134
136
  self.info = info
135
137
  self.kind = kind
@@ -137,6 +139,7 @@ class ExecutionContext:
137
139
  self.reporter = reporter
138
140
  self.control = control
139
141
  self.progress_reporter = progress_reporter
142
+ self.worker_telemetry_reporter = worker_telemetry_reporter
140
143
  self._lock = threading.Lock()
141
144
  self._finish_started = False
142
145
  self._finished = False
@@ -175,6 +178,12 @@ class ExecutionContext:
175
178
  raise RuntimeError("Progress reporting is unavailable for this execution.")
176
179
  return reporter(progress)
177
180
 
181
+ def report_worker_telemetry(self, telemetry: dict[str, JSONValue]) -> bool:
182
+ reporter = self.worker_telemetry_reporter
183
+ if reporter is None:
184
+ raise RuntimeError("Worker telemetry reporting is unavailable for this execution.")
185
+ return reporter(telemetry)
186
+
178
187
 
179
188
  _CONTEXT_LOCK = threading.RLock()
180
189
  _ACTIVE_CONTEXT: ExecutionContext | None = None
@@ -240,6 +249,20 @@ def report_progress(
240
249
  return context.report_progress(normalized)
241
250
 
242
251
 
252
+ def report_worker_telemetry(
253
+ telemetry: dict[str, JSONValue],
254
+ *,
255
+ skip_if_no_labtasker: bool = False,
256
+ ) -> bool:
257
+ context = _get_context()
258
+ if context is None:
259
+ if skip_if_no_labtasker:
260
+ return False
261
+ raise RuntimeError("No active Labtasker Task execution is available.")
262
+ normalized = validate_json_object(telemetry, field="telemetry")
263
+ return context.report_worker_telemetry(normalized)
264
+
265
+
243
266
  def cancellation_requested() -> bool:
244
267
  context = _require_python_context()
245
268
  if context.finished or context.control is None:
@@ -281,6 +304,7 @@ def _load_environment_context() -> ExecutionContext | None:
281
304
  "run_dir": "LABTASKER_RUN_DIR",
282
305
  }
283
306
  values = {field: os.environ.get(name) for field, name in names.items()}
307
+ worker_id = os.environ.get("LABTASKER_WORKER_ID")
284
308
  execution_fields = {"task_id", "run_id", "route", "run_dir"}
285
309
  present = {field for field, value in values.items() if value is not None}
286
310
  if not (present & execution_fields):
@@ -379,6 +403,31 @@ def _load_environment_context() -> ExecutionContext | None:
379
403
  progress=progress,
380
404
  )
381
405
 
406
+ worker_telemetry_reporter: WorkerTelemetryReporter | None = None
407
+ if worker_id is not None:
408
+ from labtasker.validation import validate_worker_id
409
+
410
+ try:
411
+ normalized_worker_id = validate_worker_id(worker_id)
412
+ except RequestValidationError as error:
413
+ raise ConfigError(
414
+ "invalid_config",
415
+ "Inherited Labtasker Worker context is invalid.",
416
+ {"field": "LABTASKER_WORKER_ID"},
417
+ ) from error
418
+
419
+ def report_telemetry(telemetry: dict[str, JSONValue]) -> bool:
420
+ from labtasker.worker import report_worker_telemetry_once
421
+
422
+ return report_worker_telemetry_once(
423
+ client,
424
+ queue=values["queue"] or "",
425
+ worker_id=normalized_worker_id,
426
+ telemetry=telemetry,
427
+ )
428
+
429
+ worker_telemetry_reporter = report_telemetry
430
+
382
431
  _ENV_CONTEXT = ExecutionContext(
383
432
  info=info,
384
433
  kind="command",
@@ -386,6 +435,7 @@ def _load_environment_context() -> ExecutionContext | None:
386
435
  reporter=report,
387
436
  control=None,
388
437
  progress_reporter=progress_reporter,
438
+ worker_telemetry_reporter=worker_telemetry_reporter,
389
439
  )
390
440
  return _ENV_CONTEXT
391
441
 
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
4
4
  from pathlib import Path
5
5
  from typing import Literal
6
6
 
7
- from pydantic import BaseModel, ConfigDict, field_validator, model_validator
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8
8
 
9
9
  from labtasker.types import JSONValue, TaskStatus
10
10
  from labtasker.validation import (
@@ -290,6 +290,9 @@ class WorkerObservation(ResponseModel):
290
290
  route: str
291
291
  status: Literal["idle", "busy"]
292
292
  task_id: str | None
293
+ metadata: dict[str, JSONValue] = Field(default_factory=dict)
294
+ telemetry: dict[str, JSONValue] | None = None
295
+ telemetry_updated_at: datetime | None = None
293
296
  last_seen_at: datetime
294
297
  expires_at: datetime
295
298
 
@@ -310,10 +313,25 @@ class WorkerObservation(ResponseModel):
310
313
  def validate_task(cls, value: str | None) -> str | None:
311
314
  return None if value is None else validate_task_id(value)
312
315
 
313
- @field_validator("last_seen_at", "expires_at")
316
+ @field_validator("metadata", "telemetry")
314
317
  @classmethod
315
- def validate_time(cls, value: datetime) -> datetime:
316
- return _utc_datetime(value)
318
+ def validate_worker_objects(
319
+ cls, value: dict[str, JSONValue] | None, info: object
320
+ ) -> dict[str, JSONValue] | None:
321
+ if value is None:
322
+ return None
323
+ return validate_json_object(value, field=getattr(info, "field_name", "worker"))
324
+
325
+ @field_validator("telemetry_updated_at", "last_seen_at", "expires_at")
326
+ @classmethod
327
+ def validate_time(cls, value: datetime | None) -> datetime | None:
328
+ return None if value is None else _utc_datetime(value)
329
+
330
+ @model_validator(mode="after")
331
+ def validate_telemetry_fields(self) -> WorkerObservation:
332
+ if (self.telemetry is None) != (self.telemetry_updated_at is None):
333
+ raise ValueError("telemetry and telemetry_updated_at must be both null or both present")
334
+ return self
317
335
 
318
336
 
319
337
  class WorkerPage(ResponseModel):
@@ -7,6 +7,7 @@ import os
7
7
  import secrets
8
8
  import threading
9
9
  import time
10
+ from copy import deepcopy
10
11
  from types import TracebackType
11
12
 
12
13
  import httpx
@@ -14,6 +15,8 @@ import httpx
14
15
  from labtasker.client import REQUEST_TIMEOUT_SECONDS
15
16
  from labtasker.config import ResolvedConfig
16
17
  from labtasker.local import socket_transport
18
+ from labtasker.types import JSONValue
19
+ from labtasker.validation import validate_json_object
17
20
 
18
21
  REPORT_INTERVAL_SECONDS = 60.0
19
22
  SHUTDOWN_WAIT_SECONDS = 1.0
@@ -39,10 +42,18 @@ def _make_http_client(configuration: ResolvedConfig) -> httpx.Client:
39
42
 
40
43
 
41
44
  class ObservationReporter:
42
- def __init__(self, configuration: ResolvedConfig, route: str) -> None:
45
+ def __init__(
46
+ self,
47
+ configuration: ResolvedConfig,
48
+ route: str,
49
+ metadata: dict[str, JSONValue] | None = None,
50
+ ) -> None:
43
51
  self.id = f"w_{secrets.token_urlsafe(9)}"
44
52
  self._configuration = configuration
45
53
  self._route = route
54
+ self._metadata = deepcopy(
55
+ validate_json_object({} if metadata is None else metadata, field="metadata")
56
+ )
46
57
  self._pid = os.getpid()
47
58
  self._lock = threading.Lock()
48
59
  self._wake = threading.Event()
@@ -139,6 +150,7 @@ class ObservationReporter:
139
150
  "route": self._route,
140
151
  "status": "idle" if task_id is None else "busy",
141
152
  "task_id": task_id,
153
+ "metadata": self._metadata,
142
154
  },
143
155
  )
144
156
  if response.status_code != 204:
@@ -36,7 +36,7 @@ from labtasker.models import ClaimResponse, TaskInfo
36
36
  from labtasker.observations import ObservationReporter
37
37
  from labtasker.tee import WorkerTee, configure_worker_logger
38
38
  from labtasker.types import JSONValue
39
- from labtasker.validation import RequestValidationError, validate_identifier
39
+ from labtasker.validation import RequestValidationError, validate_identifier, validate_json_object
40
40
 
41
41
  P = ParamSpec("P")
42
42
  R = TypeVar("R")
@@ -133,11 +133,15 @@ def loop(
133
133
  idle_timeout: float = 300.0,
134
134
  force_stop_timeout: float | None = None,
135
135
  max_consecutive_failures: int = 5,
136
+ metadata: dict[str, JSONValue] | None = None,
136
137
  ) -> Callable[[Callable[P, R]], Callable[P, None]]:
137
138
  _FailureGuard(max_consecutive_failures)
138
139
  normalized_route = validate_identifier(route, field="route")
139
140
  normalized_idle_timeout = _validate_idle_timeout(idle_timeout)
140
141
  normalized_force_stop_timeout = _validate_force_stop_timeout(force_stop_timeout)
142
+ normalized_metadata = validate_json_object(
143
+ {} if metadata is None else metadata, field="metadata"
144
+ )
141
145
 
142
146
  def decorate(function: Callable[P, R]) -> Callable[P, None]:
143
147
  binding = compile_binding(function)
@@ -157,6 +161,7 @@ def loop(
157
161
  idle_timeout=normalized_idle_timeout,
158
162
  force_stop_timeout=normalized_force_stop_timeout,
159
163
  max_consecutive_failures=max_consecutive_failures,
164
+ metadata=normalized_metadata,
160
165
  )
161
166
 
162
167
  return run
@@ -174,6 +179,7 @@ def _run_python_worker(
174
179
  idle_timeout: float,
175
180
  force_stop_timeout: float | None,
176
181
  max_consecutive_failures: int = 5,
182
+ metadata: dict[str, JSONValue] | None = None,
177
183
  ) -> None:
178
184
  guard = _FailureGuard(max_consecutive_failures)
179
185
  _guard_worker_topology()
@@ -181,7 +187,7 @@ def _run_python_worker(
181
187
  configure_worker_logger()
182
188
  queue_name = client.configuration.queue
183
189
  _preflight(client, queue_name)
184
- with ObservationReporter(client.configuration, route) as observer:
190
+ with ObservationReporter(client.configuration, route, metadata) as observer:
185
191
  idle_deadline: float | None = None
186
192
  while True:
187
193
  claim = client._claim(route=route, run_id=_generate_run_id(), queue=queue_name)
@@ -213,6 +219,7 @@ def _run_python_worker(
213
219
  queue=queue_name,
214
220
  route=route,
215
221
  force_stop_timeout=force_stop_timeout,
222
+ worker_id=observer.id,
216
223
  )
217
224
 
218
225
  guard.observe(result, claim.task.id)
@@ -230,6 +237,7 @@ def _run_python_claim(
230
237
  queue: str,
231
238
  route: str,
232
239
  force_stop_timeout: float | None,
240
+ worker_id: str,
233
241
  ) -> _ExecutionResult:
234
242
  try:
235
243
  journal = LocalRunJournal.create(
@@ -271,6 +279,14 @@ def _run_python_claim(
271
279
  control=control,
272
280
  )
273
281
 
282
+ def worker_telemetry_reporter(telemetry: dict[str, JSONValue]) -> bool:
283
+ return report_worker_telemetry_once(
284
+ client,
285
+ queue=queue,
286
+ worker_id=worker_id,
287
+ telemetry=telemetry,
288
+ )
289
+
274
290
  info = TaskInfo(
275
291
  **claim.task.model_dump(),
276
292
  run_id=claim.run_id,
@@ -283,6 +299,7 @@ def _run_python_claim(
283
299
  reporter=report_complete,
284
300
  control=control,
285
301
  progress_reporter=progress_reporter,
302
+ worker_telemetry_reporter=worker_telemetry_reporter,
286
303
  )
287
304
  heartbeat = Heartbeat(
288
305
  client,
@@ -403,6 +420,28 @@ def report_progress_once(
403
420
  return True
404
421
 
405
422
 
423
+ def report_worker_telemetry_once(
424
+ client: Client,
425
+ *,
426
+ queue: str,
427
+ worker_id: str,
428
+ telemetry: dict[str, JSONValue],
429
+ ) -> bool:
430
+ try:
431
+ client._report_worker_telemetry(
432
+ worker_id=worker_id,
433
+ telemetry=telemetry,
434
+ queue=queue,
435
+ )
436
+ except TransportError as error:
437
+ logger.warning("Worker telemetry transport error; continuing Task: %s", error.message)
438
+ return False
439
+ except LabtaskerError as error:
440
+ logger.warning("Worker telemetry report failed; continuing Task: %s", error.message)
441
+ return False
442
+ return True
443
+
444
+
406
445
  def _report_complete(
407
446
  client: Client,
408
447
  journal: LocalRunJournal,