datatorch 0.8.2__tar.gz → 0.8.3__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 (112) hide show
  1. {datatorch-0.8.2 → datatorch-0.8.3}/PKG-INFO +1 -1
  2. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/__init__.py +53 -14
  3. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/client.py +79 -3
  4. datatorch-0.8.3/datatorch/runner/daemon.py +372 -0
  5. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/step/step.py +29 -6
  6. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/PKG-INFO +1 -1
  7. {datatorch-0.8.2 → datatorch-0.8.3}/setup.py +1 -1
  8. datatorch-0.8.2/datatorch/runner/daemon.py +0 -210
  9. {datatorch-0.8.2 → datatorch-0.8.3}/README.md +0 -0
  10. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/__init__.py +0 -0
  11. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/agent/__init__.py +0 -0
  12. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/__init__.py +0 -0
  13. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/api.py +0 -0
  14. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/client.py +0 -0
  15. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/__init__.py +0 -0
  16. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/annotation.py +0 -0
  17. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/base.py +0 -0
  18. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/dataset.py +0 -0
  19. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/file.py +0 -0
  20. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/label.py +0 -0
  21. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/project.py +0 -0
  22. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/settings.py +0 -0
  23. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/__init__.py +0 -0
  24. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/__init__.py +0 -0
  25. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/bounding_box.py +0 -0
  26. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/segmentations.py +0 -0
  27. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/typings.py +0 -0
  28. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/source.py +0 -0
  29. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/storage_link.py +0 -0
  30. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/user.py +0 -0
  31. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/pull.py +0 -0
  32. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/__init__.py +0 -0
  33. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/import_coco.py +0 -0
  34. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/import_coco_server.py +0 -0
  35. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/import_pixmask.py +0 -0
  36. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/utils/__init__.py +0 -0
  37. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/utils/simplify.py +0 -0
  38. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/utils.py +0 -0
  39. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/where.py +0 -0
  40. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/__init__.py +0 -0
  41. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/__init__.py +0 -0
  42. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/create.py +0 -0
  43. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/pull.py +0 -0
  44. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/run.py +0 -0
  45. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/groups.py +0 -0
  46. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/import_cmds/__init__.py +0 -0
  47. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/import_cmds/coco.py +0 -0
  48. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/import_cmds/pixmask.py +0 -0
  49. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/__init__.py +0 -0
  50. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/login.py +0 -0
  51. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/logout.py +0 -0
  52. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/pull.py +0 -0
  53. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/upgrade.py +0 -0
  54. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/version.py +0 -0
  55. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/__init__.py +0 -0
  56. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/generate.py +0 -0
  57. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/run.py +0 -0
  58. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/upload.py +0 -0
  59. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/__init__.py +0 -0
  60. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/create.py +0 -0
  61. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/dir.py +0 -0
  62. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/start.py +0 -0
  63. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/spinner.py +0 -0
  64. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/upload/__init__.py +0 -0
  65. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/upload/folder.py +0 -0
  66. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/utils.py +0 -0
  67. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/__init__.py +0 -0
  68. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/env.py +0 -0
  69. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/folder.py +0 -0
  70. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/settings.py +0 -0
  71. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/directory.py +0 -0
  72. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/log_handler.py +0 -0
  73. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/logging.py +0 -0
  74. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/monitoring.py +0 -0
  75. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/__init__.py +0 -0
  76. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/__init__.py +0 -0
  77. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/action.py +0 -0
  78. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/cache.py +0 -0
  79. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/config.py +0 -0
  80. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/job/__init__.py +0 -0
  81. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/job/job.py +0 -0
  82. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/pipeline.py +0 -0
  83. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/resolver.py +0 -0
  84. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/__init__.py +0 -0
  85. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/command.py +0 -0
  86. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/docker.py +0 -0
  87. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/factory.py +0 -0
  88. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/node.py +0 -0
  89. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/python.py +0 -0
  90. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/runner.py +0 -0
  91. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/shell.py +0 -0
  92. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/step/__init__.py +0 -0
  93. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/template.py +0 -0
  94. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/services/__init__.py +0 -0
  95. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/utils/__init__.py +0 -0
  96. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/utils/docker.py +0 -0
  97. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/utils/process.py +0 -0
  98. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/__init__.py +0 -0
  99. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/converters.py +0 -0
  100. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/files.py +0 -0
  101. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/hash_table.py +0 -0
  102. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/objects.py +0 -0
  103. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/package.py +0 -0
  104. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/string_style.py +0 -0
  105. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/url.py +0 -0
  106. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/SOURCES.txt +0 -0
  107. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/dependency_links.txt +0 -0
  108. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/entry_points.txt +0 -0
  109. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/not-zip-safe +0 -0
  110. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/requires.txt +0 -0
  111. {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/top_level.txt +0 -0
  112. {datatorch-0.8.2 → datatorch-0.8.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datatorch
3
- Version: 0.8.2
3
+ Version: 0.8.3
4
4
  Summary: A CLI and library for interacting with DataTorch.
5
5
  Home-page: https://github.com/datatorch/python
6
6
  Author: DataTorch
@@ -5,9 +5,10 @@ import os
5
5
 
6
6
  from logging.handlers import RotatingFileHandler
7
7
  from asyncio import IncompleteReadError
8
+ from typing import List, Optional
8
9
 
9
10
  from .directory import runner_directory
10
- from .daemon import RunnerDaemon, tasks
11
+ from .daemon import RunnerDaemon, tasks # noqa: F401 (tasks: re-exported)
11
12
 
12
13
  from gql import Client as GqlClient
13
14
  from gql.transport.exceptions import TransportError, TransportQueryError
@@ -29,6 +30,9 @@ logger = logging.getLogger(__name__)
29
30
  _url = runner_directory.settings.api_url
30
31
  _agent_token = runner_directory.settings.runner_token
31
32
 
33
+ # The one daemon of this process; it outlives every websocket connection.
34
+ _daemon: Optional[RunnerDaemon] = None
35
+
32
36
 
33
37
  BACKOFF_INIT_WAIT = 2
34
38
  BACKOFF_FACTOR = 1.5
@@ -54,14 +58,21 @@ def setup_logging() -> None:
54
58
  logger.setLevel(logging.DEBUG)
55
59
 
56
60
 
61
+ def running_steps() -> List[asyncio.Task]:
62
+ """The step tasks this process is running right now."""
63
+ return _daemon.running_steps() if _daemon else []
64
+
65
+
57
66
  async def _exit_jobs() -> None:
58
- """Exits active running agent jobs"""
59
- logger.info(f"Exiting {len(tasks)} active jobs.")
67
+ """Cancels the running steps. A real shutdown only (`stop`): a lost
68
+ connection does not come through here any more (0.8.3)."""
69
+ steps = running_steps()
70
+ logger.info(f"Exiting {len(steps)} active step(s).")
60
71
 
61
- for task in tasks:
72
+ for task in steps:
62
73
  task.cancel()
63
74
 
64
- await asyncio.gather(*tasks, return_exceptions=True)
75
+ await asyncio.gather(*steps, return_exceptions=True)
65
76
 
66
77
 
67
78
  async def _close_transport(transport: WebsocketsTransport):
@@ -93,12 +104,24 @@ async def _exit_tasks() -> None:
93
104
 
94
105
 
95
106
  async def _restart_after_failure(transport, backoff_wait: float) -> float:
96
- """Tears down jobs, transport and tasks, then sleeps with backoff.
107
+ """The connection failed: stops this connection's services, closes the
108
+ transport and sleeps with backoff before the caller reconnects.
109
+
110
+ Running steps are left running. Before 0.8.3 every step was cancelled
111
+ here, so a four-second websocket blip (a rolling API deploy, a keepalive
112
+ ping timeout) killed hours of work; on 2026-11-09 three such drops
113
+ aborted the org-root dataset-scout seed step 46 seconds in. A step
114
+ that finishes while disconnected holds its result until the daemon is
115
+ connected again (see `RunnerDaemon._report`).
97
116
 
98
117
  Returns the next backoff wait."""
99
- await _exit_jobs()
118
+ if _daemon is not None:
119
+ _daemon.disconnect()
100
120
  await _close_transport(transport)
101
- await _exit_tasks()
121
+
122
+ steps = running_steps()
123
+ if steps:
124
+ logger.info(f"{len(steps)} step(s) keep running while the connection is down.")
102
125
 
103
126
  backoff_wait = min(BACKOFF_MAX, backoff_wait * BACKOFF_FACTOR)
104
127
  logger.debug(
@@ -116,16 +139,27 @@ async def start() -> None:
116
139
  click.style(f"Starting DataTorch Runner v{get_version()}", fg="blue", bold=True)
117
140
  )
118
141
  logger.debug(f"API Endpoint at {_url}")
119
- backoff_wait = BACKOFF_INIT_WAIT
120
142
 
121
143
  transport = DtClient.create_socket_transport(_url, _agent_token, agent=True)
122
144
  client = GqlClient(transport=transport, fetch_schema_from_transport=True)
145
+ await serve_forever(client, transport)
146
+
147
+
148
+ async def serve_forever(client, transport, daemon: Optional[RunnerDaemon] = None):
149
+ """Connects, serves, and reconnects with backoff until cancelled.
150
+
151
+ `client` is an async gql client (`async with client as session`),
152
+ `transport` its websocket transport. One `RunnerDaemon` is used for
153
+ every connection, so its running steps survive a reconnect."""
154
+ global _daemon
155
+ _daemon = daemon or RunnerDaemon()
156
+ backoff_wait = BACKOFF_INIT_WAIT
123
157
 
124
158
  while True:
125
159
  try:
126
160
  async with client as session:
127
161
  backoff_wait = BACKOFF_INIT_WAIT
128
- await RunnerDaemon.run(session)
162
+ await _daemon.serve(session)
129
163
 
130
164
  except (asyncio.CancelledError, InvalidURI):
131
165
  break
@@ -148,7 +182,6 @@ async def start() -> None:
148
182
  asyncio.TimeoutError,
149
183
  ) as e:
150
184
  logger.error(e)
151
- backoff_wait = await _restart_after_failure(transport, backoff_wait)
152
185
 
153
186
  except Exception:
154
187
  # Last resort: the loop must never die. On 2026-09-12 an
@@ -156,13 +189,19 @@ async def start() -> None:
156
189
  # with no agent loop, so the runner showed OFFLINE while its
157
190
  # window looked fine.
158
191
  logger.exception("Unexpected error in the agent loop; restarting.")
192
+
193
+ try:
159
194
  backoff_wait = await _restart_after_failure(transport, backoff_wait)
195
+ except asyncio.CancelledError:
196
+ # A shutdown that lands during the backoff sleep.
197
+ break
160
198
 
161
199
  logger.info("Exiting job processing task.")
162
200
 
163
201
 
164
202
  async def stop() -> None:
165
- """Stop all run tasks."""
203
+ """Stop all run tasks (SIGINT/SIGTERM): running steps are cancelled,
204
+ then every other task, then the loop."""
166
205
 
167
206
  print(" ")
168
207
 
@@ -171,8 +210,8 @@ async def stop() -> None:
171
210
  logger.info("Closing runner jobs.")
172
211
  await _exit_jobs()
173
212
 
174
- logger.info("Closing runner jobs.")
175
- await _exit_jobs()
213
+ if _daemon is not None:
214
+ _daemon.disconnect()
176
215
 
177
216
  logger.info("Closing all other tasks.")
178
217
  await _exit_tasks()
@@ -2,13 +2,52 @@ from typing import AsyncGenerator, List, Optional, cast
2
2
  from gql import gql
3
3
  from .directory import runner_directory
4
4
  from gql.client import AsyncClientSession
5
+ from gql.transport.exceptions import TransportError, TransportQueryError
6
+ from websockets.exceptions import ConnectionClosed
5
7
 
6
8
  from typing_extensions import TypedDict
9
+ from asyncio import IncompleteReadError
10
+ import asyncio
7
11
  import logging
8
12
 
9
13
  logger = logging.getLogger(__name__)
10
14
 
11
15
 
16
+ class RunnerDisconnected(ConnectionError):
17
+ """The runner has no live websocket session right now.
18
+
19
+ `AgentApiClient.execute` raises it at once instead of sending into a
20
+ dead transport. Callers whose message must get through (a finished
21
+ step's result) wait on `AgentApiClient.wait_connected` and try again;
22
+ callers that can afford to lose a message (a log batch, a metric) let
23
+ it fail and send the next one.
24
+ """
25
+
26
+
27
+ def is_transient(error: BaseException) -> bool:
28
+ """Whether an API call failed because of the connection, so the same
29
+ call may succeed once the runner is connected again.
30
+
31
+ A `TransportQueryError` carrying GraphQL errors is the server's answer
32
+ (the mutation was refused) and is not transient; the same class with
33
+ no errors is gql's "query completed without any answer", which is the
34
+ socket dropping mid-call.
35
+ """
36
+ if isinstance(error, TransportQueryError):
37
+ return not error.errors
38
+ return isinstance(
39
+ error,
40
+ (
41
+ RunnerDisconnected,
42
+ TransportError,
43
+ ConnectionClosed,
44
+ OSError,
45
+ IncompleteReadError,
46
+ asyncio.TimeoutError,
47
+ ),
48
+ )
49
+
50
+
12
51
  class Log(TypedDict):
13
52
  createdAt: str
14
53
  message: str
@@ -67,8 +106,41 @@ STEP_DISPATCH_FIELDS = """
67
106
 
68
107
 
69
108
  class AgentApiClient(object):
70
- def __init__(self, session: AsyncClientSession):
109
+ """The runner's side of the runner wire protocol.
110
+
111
+ One client lives as long as the daemon; the gql session behind it
112
+ changes with every reconnect (`attach`/`detach`). While detached every
113
+ call fails at once with `RunnerDisconnected`, so a step that finishes
114
+ during an outage neither hangs on a dead socket nor reports into it.
115
+ """
116
+
117
+ def __init__(self, session: Optional[AsyncClientSession] = None):
118
+ self.session = session
119
+ self._connected = asyncio.Event()
120
+ if session is not None:
121
+ self._connected.set()
122
+
123
+ @property
124
+ def connected(self) -> bool:
125
+ return self._connected.is_set()
126
+
127
+ def attach(self, session: AsyncClientSession) -> None:
128
+ """A (re)connected session: calls go through again."""
71
129
  self.session = session
130
+ self._connected.set()
131
+
132
+ def detach(self) -> None:
133
+ """The connection is gone: refuse calls until the next `attach`."""
134
+ self.session = None
135
+ self._connected.clear()
136
+
137
+ async def wait_connected(self, timeout: Optional[float] = None) -> bool:
138
+ """Waits until a session is attached. False when `timeout` ran out."""
139
+ try:
140
+ await asyncio.wait_for(self._connected.wait(), timeout)
141
+ return True
142
+ except asyncio.TimeoutError:
143
+ return False
72
144
 
73
145
  def agent_steps(self):
74
146
  """Subscribe to ready steps dispatched to this runner."""
@@ -300,10 +372,14 @@ class AgentApiClient(object):
300
372
  logger.debug(f"Transport close raised while forcing reconnect: {e}")
301
373
 
302
374
  async def execute(self, query, *args, params: dict = {}, **kwargs) -> dict:
303
- """Wrapper around execute"""
375
+ """Wrapper around execute. Raises `RunnerDisconnected` while no
376
+ session is attached (see the class docstring)."""
377
+ session = self.session
378
+ if session is None or not self._connected.is_set():
379
+ raise RunnerDisconnected("The runner is not connected to DataTorch.")
304
380
  removed_none = dict((k, v) for k, v in params.items() if v is not None)
305
381
  if type(query) == str:
306
382
  query = gql(query)
307
- return await self.session.execute(
383
+ return await session.execute(
308
384
  query, *args, variable_values=removed_none, **kwargs
309
385
  )
@@ -0,0 +1,372 @@
1
+ import os
2
+ import logging
3
+ import asyncio
4
+
5
+ from typing import Awaitable, Callable, Dict, List, Optional
6
+ from .client import AgentApiClient, AgentStepDispatch, is_transient
7
+ from .pipelines.template import Variables
8
+ from .log_handler import AgentAPIHandler
9
+ from .monitoring import AgentSystemStats
10
+ from .directory import runner_directory
11
+
12
+ from gql.client import AsyncClientSession
13
+ from datatorch.runner.pipelines.step import Step
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Every task the daemon spawned that is still running: step tasks and the
18
+ # per-connection service tasks (monitoring, the cancel and command
19
+ # listeners). A task removes itself when it finishes, so the list counts
20
+ # what is really in flight. Before 0.8.3 it only ever grew (the prod runner
21
+ # logged "Exiting 71 active jobs", then 74, then 77 across three reconnects
22
+ # on 2026-11-09).
23
+ tasks: List[asyncio.Task] = []
24
+
25
+ # A finished step's result (its last log batch and the completion
26
+ # mutation) is retried across a lost connection for this long; after that
27
+ # the runner logs the loss and drops the result. The server fails a
28
+ # RUNNING step once its runner has been unseen for about two minutes, so a
29
+ # much longer outage has already decided the step's fate there.
30
+ REPORT_DEADLINE_SECONDS = 600
31
+ # Pause between two attempts while connected (the socket may be up but the
32
+ # call still failing, e.g. the API pod is restarting behind it).
33
+ REPORT_RETRY_SECONDS = 5
34
+ # One attempt may not sit on a half-open socket for longer than this.
35
+ REPORT_CALL_TIMEOUT_SECONDS = 60
36
+
37
+
38
+ def _track(task: asyncio.Task) -> asyncio.Task:
39
+ tasks.append(task)
40
+ task.add_done_callback(_untrack)
41
+ return task
42
+
43
+
44
+ def _untrack(task: asyncio.Task) -> None:
45
+ try:
46
+ tasks.remove(task)
47
+ except ValueError:
48
+ pass
49
+
50
+
51
+ class RunnerDaemon(object):
52
+ """
53
+ Runner protocol v2: the server sequences steps. The runner receives one
54
+ ready step at a time — its inputs already resolved server-side —
55
+ claims it, executes it, and reports the result. Job-level state and
56
+ cross-step dataflow live entirely on the server.
57
+
58
+ One daemon lives for the whole process and outlives the websocket:
59
+ `serve(session)` runs one connection's worth of work (recover pending
60
+ steps, listen for pushes, monitor) and returns or raises when that
61
+ connection ends. Running steps belong to the daemon, not to the
62
+ connection, so a dropped socket leaves them running; they report
63
+ through whichever session is attached when they finish (`_report`).
64
+ """
65
+
66
+ @classmethod
67
+ async def run(cls, session):
68
+ """One connection, one daemon (the pre-0.8.3 shape, kept for
69
+ callers outside this package). `runner.start` keeps a single
70
+ daemon across reconnects instead."""
71
+ daemon = cls()
72
+ await daemon.serve(session)
73
+
74
+ def __init__(self, api: Optional[AgentApiClient] = None):
75
+ self.api = api or AgentApiClient()
76
+ self.directory = runner_directory
77
+ # Running step tasks, keyed by stepId, so a server cancel/timeout
78
+ # signal can abort the right one. Entries are cleaned up when the
79
+ # step finishes.
80
+ self._step_tasks: Dict[str, asyncio.Task] = {}
81
+ # Tasks that live only as long as the current connection.
82
+ self._service_tasks: List[asyncio.Task] = []
83
+ self.system_stats: Optional[AgentSystemStats] = None
84
+ logger.debug(f"Switch to agent directory: {self.directory.dir}")
85
+ os.chdir(self.directory.dir)
86
+
87
+ self._init_logger()
88
+
89
+ def _init_logger(self):
90
+ self.logger = logging.getLogger("datatorch.runner")
91
+ self.logger_api_handler = AgentAPIHandler(self.api)
92
+ self.logger.addHandler(self.logger_api_handler)
93
+ self.logger.debug("Runner logger has been initialized.")
94
+
95
+ # -- connection lifecycle -------------------------------------------
96
+
97
+ async def serve(self, session: AsyncClientSession):
98
+ """Works one connection until it ends.
99
+
100
+ Attaches the session (steps finishing now report through it),
101
+ starts the per-connection services, then runs the dispatch loop.
102
+ However the loop ends, the services are stopped and the API is
103
+ detached; running steps are left alone."""
104
+ self.api.attach(session)
105
+ try:
106
+ self._start_services()
107
+ await self.process_loop()
108
+ finally:
109
+ self.disconnect()
110
+
111
+ def disconnect(self) -> None:
112
+ """The connection is gone (or being closed).
113
+
114
+ Refuses API calls until the next `serve` attaches a session and
115
+ cancels the per-connection services. Running steps are NOT
116
+ cancelled: a websocket blip is not a reason to kill a four-hour
117
+ step. Idempotent."""
118
+ self.api.detach()
119
+ for task in self._service_tasks:
120
+ if not task.done():
121
+ task.cancel()
122
+ self._service_tasks = []
123
+
124
+ def running_steps(self) -> List[asyncio.Task]:
125
+ """The step tasks still in flight."""
126
+ return [task for task in self._step_tasks.values() if not task.done()]
127
+
128
+ def _service(self, coro) -> asyncio.Task:
129
+ loop = asyncio.get_running_loop()
130
+ task = _track(loop.create_task(coro))
131
+ self._service_tasks.append(task)
132
+ return task
133
+
134
+ def _start_services(self):
135
+ self.system_stats = AgentSystemStats(self)
136
+ self._service(self.system_stats.start())
137
+
138
+ # -- dispatch -------------------------------------------------------
139
+
140
+ async def process_loop(self):
141
+ """Drains steps missed while offline, then waits for pushes.
142
+
143
+ Runs the dispatch stream alongside a cancel-signal listener: the
144
+ server pushes a stop signal when a running step is canceled or
145
+ times out, and the agent aborts that step's task.
146
+ """
147
+ pending = await self.api.pending_steps()
148
+ if pending:
149
+ logger.info(f"Recovered {len(pending)} pending step(s) on connect.")
150
+ for dispatch in pending:
151
+ self._start_step(dispatch)
152
+
153
+ logger.info("Waiting for steps.")
154
+ cancel_task = self._service(self._cancel_loop())
155
+ command_task = self._service(self._command_loop())
156
+ try:
157
+ async for request in self.api.agent_steps():
158
+ self._start_step(request.get("step"))
159
+ finally:
160
+ cancel_task.cancel()
161
+ command_task.cancel()
162
+
163
+ async def _command_loop(self):
164
+ """Answers the server's requests about this runner's own action
165
+ folders (read or write `actions/local/<name>/<tag>`). Nothing
166
+ outside those folders is ever read or written: the directory
167
+ refuses any other path. A failure is the answer's error, never a
168
+ crash of the loop."""
169
+ try:
170
+ async for request in self.api.runner_commands():
171
+ command = request.get("command") or {}
172
+ self._service(self._answer_command(command))
173
+ except asyncio.CancelledError:
174
+ raise
175
+ except Exception as ex:
176
+ # A server from before runner commands has no such subscription;
177
+ # steps still flow, only editing from DataTorch is off.
178
+ logger.warning(f"Runner commands are not available: {ex}")
179
+
180
+ async def _answer_command(self, command: dict):
181
+ command_id = command.get("id")
182
+ kind = command.get("kind")
183
+ payload = command.get("payload") or {}
184
+ result, error = None, None
185
+ try:
186
+ name, tag = payload.get("name"), payload.get("tag")
187
+ if kind == "READ_LOCAL_ACTION":
188
+ result = {"files": runner_directory.local_action_files(name, tag)}
189
+ elif kind == "WRITE_LOCAL_ACTION":
190
+ files = runner_directory.write_local_action_files(
191
+ name, tag, payload.get("files") or [], payload.get("delete") or []
192
+ )
193
+ result = {"files": files}
194
+ logger.info(f"Wrote actions/local/{name}/{tag} on request.")
195
+ # Report the folder's yaml now, so the picker lists it at once.
196
+ try:
197
+ await self.api.report_local_actions(
198
+ runner_directory.local_actions()
199
+ )
200
+ except Exception as ex:
201
+ logger.warning(
202
+ f"Could not report local actions after a write: {ex}"
203
+ )
204
+ else:
205
+ error = f"Unknown runner command '{kind}'."
206
+ except (ValueError, FileNotFoundError, OSError) as ex:
207
+ error = str(ex)
208
+ except Exception as ex: # the loop must survive anything
209
+ logger.exception("Runner command failed.")
210
+ error = f"{type(ex).__name__}: {ex}"
211
+ try:
212
+ await self.api.complete_runner_command(
213
+ command_id, result=result, error=error
214
+ )
215
+ except Exception as ex:
216
+ logger.warning(f"Could not answer runner command {command_id}: {ex}")
217
+
218
+ async def _cancel_loop(self):
219
+ """Aborts a running step's task when the server signals a stop."""
220
+ async for request in self.api.agent_step_cancels():
221
+ cancel = request.get("cancel") or {}
222
+ step_id = cancel.get("stepId")
223
+ task = self._step_tasks.get(step_id)
224
+ if task and not task.done():
225
+ logger.info(f"Received stop signal for step {step_id}.")
226
+ task.cancel()
227
+ else:
228
+ # Unknown/finished step — the signal is best-effort and the
229
+ # server has already moved the step terminal.
230
+ logger.debug(f"Stop signal for step {step_id} ignored (not running).")
231
+
232
+ def _start_step(self, dispatch: AgentStepDispatch):
233
+ """Schedules a step and tracks its task for cancellation."""
234
+ step_id = dispatch.get("stepId")
235
+ running = self._step_tasks.get(step_id)
236
+ if running and not running.done():
237
+ # A second push for a step this process already works on (a
238
+ # dispatch repeated across a reconnect). The claim would be
239
+ # refused anyway; don't start a second task for it.
240
+ logger.debug(f"Step {step_id} is already running here; push ignored.")
241
+ return
242
+ loop = asyncio.get_running_loop()
243
+ task = _track(loop.create_task(self._run_step(dispatch)))
244
+ self._step_tasks[step_id] = task
245
+
246
+ async def _run_step(self, dispatch: AgentStepDispatch):
247
+ """Claims and executes a single dispatched step."""
248
+ step_id = dispatch.get("stepId")
249
+ step_name = dispatch.get("stepName") or dispatch.get("action")
250
+
251
+ try:
252
+ try:
253
+ claimed = await self.api.claim_step(step_id)
254
+ except Exception as e:
255
+ # The connection went before the claim got through. The
256
+ # step stays QUEUED on the server and comes back through
257
+ # `pending_steps` on reconnect (or the reconciler
258
+ # re-drives it), so nothing is lost by stopping here.
259
+ logger.warning(
260
+ f"Could not claim step {step_id} ({e}); "
261
+ "it is offered again on reconnect."
262
+ )
263
+ return
264
+ if not claimed:
265
+ # Another process (or an earlier duplicate dispatch) owns it.
266
+ logger.debug(f"Step {step_id} already claimed; skipping.")
267
+ return
268
+
269
+ logger.info(f"Step received (name: {step_name}, id: {step_id})")
270
+ step = Step.from_dispatch(dispatch, api=self.api)
271
+
272
+ try:
273
+ outputs = await step.run(Variables())
274
+ except asyncio.CancelledError:
275
+ # A server stop signal (the step is already terminal
276
+ # server-side) or a real shutdown of this runner: the child
277
+ # process was killed, so don't report a result. The last
278
+ # log lines are best effort: the connection may be the very
279
+ # thing that is gone.
280
+ logger.info(f"Step {step_id} aborted.")
281
+ try:
282
+ await step.upload_logs()
283
+ except Exception:
284
+ pass
285
+ raise
286
+ except Exception as e:
287
+ error_message = str(e)
288
+ logger.error(f"Step {step_id} failed: {error_message}")
289
+ step.log(f"Step failed: {error_message}.")
290
+ await self._report(
291
+ step,
292
+ lambda: self.api.complete_step(
293
+ step_id, success=False, error_message=error_message
294
+ ),
295
+ )
296
+ return
297
+
298
+ reported = await self._report(
299
+ step,
300
+ lambda: self.api.complete_step(
301
+ step_id,
302
+ success=True,
303
+ output=outputs,
304
+ rendered_input=step.rendered_inputs,
305
+ ),
306
+ )
307
+ if reported:
308
+ logger.info(f"Step finished (name: {step_name}, id: {step_id})")
309
+ finally:
310
+ self._step_tasks.pop(step_id, None)
311
+
312
+ async def _report(self, step: Step, send: Callable[[], Awaitable[bool]]) -> bool:
313
+ """Gets a finished step's result to the server, across an outage.
314
+
315
+ Ships the step's remaining log lines, then the completion. While
316
+ the runner is disconnected it waits for the next session; a call
317
+ that fails because of the connection is retried. It gives up after
318
+ `REPORT_DEADLINE_SECONDS`, logging the loss. Returns whether the
319
+ server accepted the result: False also when the server had already
320
+ moved the step terminal (failed by the reconciler while this runner
321
+ was offline), which is logged and otherwise absorbed.
322
+ """
323
+ loop = asyncio.get_running_loop()
324
+ deadline = loop.time() + REPORT_DEADLINE_SECONDS
325
+ logs_sent = False
326
+ waiting_logged = False
327
+ while True:
328
+ remaining = deadline - loop.time()
329
+ if remaining <= 0:
330
+ logger.error(
331
+ f"Step {step.id}: could not report its result within "
332
+ f"{REPORT_DEADLINE_SECONDS} seconds of finishing; the result "
333
+ "is dropped (the server fails a step whose runner stays "
334
+ "unseen, so it is not left hanging)."
335
+ )
336
+ return False
337
+ if not self.api.connected:
338
+ if not waiting_logged:
339
+ logger.warning(
340
+ f"Step {step.id} finished while disconnected; holding "
341
+ "its result until the connection is back."
342
+ )
343
+ waiting_logged = True
344
+ await self.api.wait_connected(timeout=remaining)
345
+ continue
346
+ try:
347
+ if not logs_sent:
348
+ await asyncio.wait_for(
349
+ step.upload_logs(), REPORT_CALL_TIMEOUT_SECONDS
350
+ )
351
+ logs_sent = True
352
+ accepted = await asyncio.wait_for(send(), REPORT_CALL_TIMEOUT_SECONDS)
353
+ except asyncio.CancelledError:
354
+ raise
355
+ except Exception as e:
356
+ if not is_transient(e):
357
+ logger.error(f"Step {step.id}: the server refused the result: {e}")
358
+ return False
359
+ logger.warning(f"Step {step.id}: reporting failed ({e}); retrying.")
360
+ await asyncio.sleep(min(REPORT_RETRY_SECONDS, max(remaining, 0)))
361
+ continue
362
+ if not accepted:
363
+ logger.warning(
364
+ f"Step {step.id}: the server did not accept the result; the "
365
+ "step was already terminal there (failed while this runner "
366
+ "was offline, canceled, or timed out)."
367
+ )
368
+ return bool(accepted)
369
+
370
+
371
+ # Pre-rename name, kept for callers outside this package.
372
+ Agent = RunnerDaemon