velophex 0.2.0__py3-none-any.whl
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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""One executor session: hello, optional prepare, start, telemetry, one terminal message.
|
|
2
|
+
|
|
3
|
+
Threading, because it is the part a reviewer has to agree with: the protocol reader blocks on
|
|
4
|
+
stdin, and a handler may block for hours, so the two cannot share a thread. The session reads
|
|
5
|
+
``hello`` and ``start`` on the main thread, then hands the handler to a worker thread and
|
|
6
|
+
keeps reading, so a ``stop`` that arrives mid-run is seen immediately. The frame writer is
|
|
7
|
+
thread-safe, which is what lets log lines and the terminal message come from either thread.
|
|
8
|
+
|
|
9
|
+
A run ends with exactly one of ``result``, ``canceled`` or ``fault``, after which the process
|
|
10
|
+
exits. Anything the handler raises that is not a :class:`velophex.BusinessError` is an
|
|
11
|
+
``Application`` fault; a package or handler that will not load is ``Application`` with
|
|
12
|
+
``INVALID_PACKAGE``, matching the workflow executor so the two runtimes retry alike.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import inspect
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
import traceback
|
|
22
|
+
from dataclasses import replace
|
|
23
|
+
from typing import Any, TextIO
|
|
24
|
+
|
|
25
|
+
from velophex.contracts.executor import (
|
|
26
|
+
CURRENT_VERSION,
|
|
27
|
+
FAULT_ENVELOPE_VERSION,
|
|
28
|
+
MINIMUM_VERSION,
|
|
29
|
+
CanceledMessage,
|
|
30
|
+
ExecutionContextInfo,
|
|
31
|
+
ExecutorCapabilities,
|
|
32
|
+
ExecutorMessage,
|
|
33
|
+
ExecutorProtocolException,
|
|
34
|
+
FaultInfo,
|
|
35
|
+
FaultMessage,
|
|
36
|
+
FaultRetryScopes,
|
|
37
|
+
HelloAckMessage,
|
|
38
|
+
HelloMessage,
|
|
39
|
+
PreparedMessage,
|
|
40
|
+
PrepareMessage,
|
|
41
|
+
RequestStopMessage,
|
|
42
|
+
ResultMessage,
|
|
43
|
+
StartMessage,
|
|
44
|
+
StopMessage,
|
|
45
|
+
SubResultMessage,
|
|
46
|
+
select_version,
|
|
47
|
+
)
|
|
48
|
+
from velophex.contracts.runtimes import (
|
|
49
|
+
ExecutionPhases,
|
|
50
|
+
FaultCodes,
|
|
51
|
+
FaultKinds,
|
|
52
|
+
LogLevels,
|
|
53
|
+
RuntimeKind,
|
|
54
|
+
SignalReasons,
|
|
55
|
+
)
|
|
56
|
+
from velophex.entrypoints import EntryPointDefinition, EntryPointInputError, normalize_outputs
|
|
57
|
+
from velophex.errors import BusinessError, VelophexError
|
|
58
|
+
from velophex.runtime import _session as ambient
|
|
59
|
+
from velophex.worker.framing import FrameReader
|
|
60
|
+
from velophex.worker.loader import LoadedEntryPoint, PackageLoadError, load_entry_point
|
|
61
|
+
from velophex.worker.outbox import ExecutorOutbox, WorkerLogHandler
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"EXIT_OK",
|
|
65
|
+
"EXIT_PROTOCOL_VIOLATION",
|
|
66
|
+
"WORKER_NAME",
|
|
67
|
+
"WorkerSession",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
#: The name the worker reports in ``hello-ack``.
|
|
71
|
+
WORKER_NAME = "velophex.worker"
|
|
72
|
+
|
|
73
|
+
EXIT_OK = 0
|
|
74
|
+
EXIT_PROTOCOL_VIOLATION = 2
|
|
75
|
+
|
|
76
|
+
#: How long to wait past the grace period before giving up on a cooperative stop. The parent
|
|
77
|
+
#: kills the process tree at the grace deadline, so this only matters when the parent is slow.
|
|
78
|
+
_STOP_SLACK_SECONDS = 5.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WorkerSession:
|
|
82
|
+
"""Serves one executor session over an authenticated frame pair."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
reader: FrameReader,
|
|
87
|
+
outbox: ExecutorOutbox,
|
|
88
|
+
*,
|
|
89
|
+
runtime_version: str,
|
|
90
|
+
worker_version: str,
|
|
91
|
+
diagnostics: TextIO | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._reader = reader
|
|
94
|
+
self._outbox = outbox
|
|
95
|
+
self._runtime_version = runtime_version
|
|
96
|
+
self._worker_version = worker_version
|
|
97
|
+
self._diagnostics = diagnostics
|
|
98
|
+
self._session: ambient.RunSession | None = None
|
|
99
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
100
|
+
self._handler_task: asyncio.Task[Any] | None = None
|
|
101
|
+
self._parent_capabilities: frozenset[str] = frozenset()
|
|
102
|
+
|
|
103
|
+
def run(self) -> int:
|
|
104
|
+
"""Serve the session and return the process exit code."""
|
|
105
|
+
try:
|
|
106
|
+
if not self._handshake():
|
|
107
|
+
return EXIT_PROTOCOL_VIOLATION
|
|
108
|
+
|
|
109
|
+
while True:
|
|
110
|
+
message = self._reader.read()
|
|
111
|
+
|
|
112
|
+
if message is None:
|
|
113
|
+
return EXIT_OK
|
|
114
|
+
|
|
115
|
+
if isinstance(message, PrepareMessage):
|
|
116
|
+
self._outbox.send(self._prepare(message))
|
|
117
|
+
elif isinstance(message, StartMessage):
|
|
118
|
+
self._send_terminal(self._start(message))
|
|
119
|
+
return EXIT_OK
|
|
120
|
+
elif isinstance(message, StopMessage):
|
|
121
|
+
# Nothing runs yet: the session ends as canceled.
|
|
122
|
+
self._outbox.send(CanceledMessage(reason=message.reason or SignalReasons.USER))
|
|
123
|
+
return EXIT_OK
|
|
124
|
+
else:
|
|
125
|
+
self._note(f"Ignoring unexpected '{message.type_name}' before start.")
|
|
126
|
+
except ExecutorProtocolException as exception:
|
|
127
|
+
self._note(f"Protocol violation ({exception.error}): {exception}")
|
|
128
|
+
return EXIT_PROTOCOL_VIOLATION
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------- handshake
|
|
131
|
+
|
|
132
|
+
def _handshake(self) -> bool:
|
|
133
|
+
message = self._reader.read()
|
|
134
|
+
|
|
135
|
+
if not isinstance(message, HelloMessage):
|
|
136
|
+
self._note("The first message was not 'hello'.")
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
version = select_version(message.minimum_version, message.maximum_version)
|
|
140
|
+
self._parent_capabilities = frozenset(message.capabilities)
|
|
141
|
+
|
|
142
|
+
if version is None:
|
|
143
|
+
self._outbox.send(
|
|
144
|
+
FaultMessage(
|
|
145
|
+
FaultInfo(
|
|
146
|
+
kind=FaultKinds.INFRASTRUCTURE,
|
|
147
|
+
code=FaultCodes.EXECUTOR_PROTOCOL_VIOLATION,
|
|
148
|
+
message=(
|
|
149
|
+
f"The parent speaks executor protocol {message.minimum_version}-{message.maximum_version}; "
|
|
150
|
+
f"this worker speaks {MINIMUM_VERSION}-{CURRENT_VERSION}."
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
self._outbox.send(
|
|
158
|
+
HelloAckMessage(
|
|
159
|
+
version=version,
|
|
160
|
+
runtime=RuntimeKind.PYTHON,
|
|
161
|
+
runtime_version=self._runtime_version,
|
|
162
|
+
executor_version=self._worker_version,
|
|
163
|
+
# Only should-stop: the Python environment is prepared by the Runner's uv
|
|
164
|
+
# adapter before this process starts (ADR-P008), there is no Python debugger
|
|
165
|
+
# over this protocol yet, and per-element progress has no meaning for a
|
|
166
|
+
# handler call. request-stop raises velophex.should_stop().
|
|
167
|
+
capabilities=[ExecutorCapabilities.SHOULD_STOP],
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- prepare
|
|
174
|
+
|
|
175
|
+
def _prepare(self, message: PrepareMessage) -> ExecutorMessage:
|
|
176
|
+
"""Answer a ``prepare`` a conforming parent should not have sent.
|
|
177
|
+
|
|
178
|
+
The worker does not offer the ``prepare`` capability: by the time it runs, ``uv sync``
|
|
179
|
+
has already built the environment it is running in. Answering ``prepared`` rather than
|
|
180
|
+
faulting keeps a parent that asks anyway from stalling.
|
|
181
|
+
"""
|
|
182
|
+
self._note("Ignoring 'prepare': the Python environment is prepared before the worker starts.")
|
|
183
|
+
return PreparedMessage(environment_root=message.environment_root, fingerprint=None)
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------- start
|
|
186
|
+
|
|
187
|
+
def _start(self, message: StartMessage) -> ExecutorMessage:
|
|
188
|
+
try:
|
|
189
|
+
loaded = load_entry_point(message.package_root, message.entry_point_id)
|
|
190
|
+
except PackageLoadError as error:
|
|
191
|
+
self._outbox.log(LogLevels.ERROR, str(error), ExecutionPhases.ENVIRONMENT_PREPARE)
|
|
192
|
+
return FaultMessage(
|
|
193
|
+
FaultInfo(
|
|
194
|
+
kind=FaultKinds.APPLICATION,
|
|
195
|
+
code=FaultCodes.INVALID_PACKAGE,
|
|
196
|
+
message=str(error),
|
|
197
|
+
retryable=False,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
arguments = loaded.definition.bind(message.inputs)
|
|
203
|
+
except EntryPointInputError as error:
|
|
204
|
+
return FaultMessage(
|
|
205
|
+
FaultInfo(
|
|
206
|
+
kind=FaultKinds.APPLICATION,
|
|
207
|
+
code=FaultCodes.INVALID_INPUT,
|
|
208
|
+
message=str(error),
|
|
209
|
+
detail=" ".join(error.pointers),
|
|
210
|
+
retryable=False,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return self._invoke(loaded, arguments, message.context)
|
|
215
|
+
|
|
216
|
+
def _invoke(
|
|
217
|
+
self,
|
|
218
|
+
loaded: LoadedEntryPoint,
|
|
219
|
+
arguments: dict[str, Any],
|
|
220
|
+
context: ExecutionContextInfo,
|
|
221
|
+
) -> ExecutorMessage:
|
|
222
|
+
session = ambient.RunSession.create(context, loaded.definition, loaded.project.name)
|
|
223
|
+
ambient.set_session(session)
|
|
224
|
+
self._session = session
|
|
225
|
+
|
|
226
|
+
if ExecutorCapabilities.SUB_INVOKE in self._parent_capabilities:
|
|
227
|
+
session.enable_sub_invoke(self._outbox.send)
|
|
228
|
+
|
|
229
|
+
handler_log = WorkerLogHandler(self._outbox)
|
|
230
|
+
root = logging.getLogger()
|
|
231
|
+
previous_level = root.level
|
|
232
|
+
root.addHandler(handler_log)
|
|
233
|
+
|
|
234
|
+
if previous_level > logging.INFO or previous_level == logging.NOTSET:
|
|
235
|
+
root.setLevel(logging.INFO)
|
|
236
|
+
|
|
237
|
+
outcome: list[ExecutorMessage] = []
|
|
238
|
+
finished = threading.Event()
|
|
239
|
+
|
|
240
|
+
def body() -> None:
|
|
241
|
+
try:
|
|
242
|
+
outcome.append(self._call(loaded.definition, arguments))
|
|
243
|
+
except BaseException as error:
|
|
244
|
+
outcome.append(self._fault_from(error, session))
|
|
245
|
+
finally:
|
|
246
|
+
finished.set()
|
|
247
|
+
|
|
248
|
+
worker = threading.Thread(target=body, name="velophex-handler", daemon=True)
|
|
249
|
+
|
|
250
|
+
self._outbox.log(LogLevels.INFORMATION, f"Started '{loaded.entry_point.id}'.", ExecutionPhases.EXECUTION)
|
|
251
|
+
worker.start()
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
self._listen_in_background(finished)
|
|
255
|
+
finished.wait()
|
|
256
|
+
finally:
|
|
257
|
+
root.removeHandler(handler_log)
|
|
258
|
+
root.setLevel(previous_level)
|
|
259
|
+
ambient.clear_session()
|
|
260
|
+
self._session = None
|
|
261
|
+
|
|
262
|
+
if outcome:
|
|
263
|
+
terminal = outcome[0]
|
|
264
|
+
else:
|
|
265
|
+
terminal = FaultMessage(
|
|
266
|
+
FaultInfo(
|
|
267
|
+
kind=FaultKinds.APPLICATION,
|
|
268
|
+
code=FaultCodes.EXECUTOR_CRASHED,
|
|
269
|
+
message="The handler thread ended without an outcome.",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# A handler that returned normally after a stop was asked for still ended because of
|
|
274
|
+
# the stop; the job is Canceled, not Succeeded.
|
|
275
|
+
if session.stop_requested and isinstance(terminal, ResultMessage):
|
|
276
|
+
return CanceledMessage(reason=session.stop_reason or SignalReasons.USER)
|
|
277
|
+
|
|
278
|
+
return terminal
|
|
279
|
+
|
|
280
|
+
def _call(self, definition: EntryPointDefinition, arguments: dict[str, Any]) -> ExecutorMessage:
|
|
281
|
+
if definition.is_async:
|
|
282
|
+
outputs = self._call_async(definition, arguments)
|
|
283
|
+
else:
|
|
284
|
+
outputs = definition.function(**arguments)
|
|
285
|
+
|
|
286
|
+
if inspect.isawaitable(outputs):
|
|
287
|
+
# A sync function that returns a coroutine (a wrapped handler) still has to
|
|
288
|
+
# be awaited, or the automation silently does nothing.
|
|
289
|
+
outputs = asyncio.run(outputs)
|
|
290
|
+
|
|
291
|
+
return ResultMessage(outputs=normalize_outputs(outputs))
|
|
292
|
+
|
|
293
|
+
def _call_async(self, definition: EntryPointDefinition, arguments: dict[str, Any]) -> Any:
|
|
294
|
+
async def main() -> Any:
|
|
295
|
+
self._loop = asyncio.get_running_loop()
|
|
296
|
+
self._handler_task = asyncio.ensure_future(definition.function(**arguments))
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
return await self._handler_task
|
|
300
|
+
finally:
|
|
301
|
+
self._loop = None
|
|
302
|
+
self._handler_task = None
|
|
303
|
+
|
|
304
|
+
return asyncio.run(main())
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------- control channel
|
|
307
|
+
|
|
308
|
+
def _listen_in_background(self, finished: threading.Event) -> None:
|
|
309
|
+
"""Start reading control messages while the handler runs.
|
|
310
|
+
|
|
311
|
+
The reader blocks on stdin, so it needs a thread of its own; it is a daemon because
|
|
312
|
+
the process exits right after the terminal message, with the read still outstanding.
|
|
313
|
+
"""
|
|
314
|
+
reader = threading.Thread(target=self._listen_loop, args=(finished,), name="velophex-control", daemon=True)
|
|
315
|
+
reader.start()
|
|
316
|
+
|
|
317
|
+
def _listen_loop(self, finished: threading.Event) -> None:
|
|
318
|
+
while not finished.is_set():
|
|
319
|
+
try:
|
|
320
|
+
message = self._reader.read()
|
|
321
|
+
except ExecutorProtocolException as exception:
|
|
322
|
+
self._note(f"Protocol violation ({exception.error}): {exception}")
|
|
323
|
+
message = None
|
|
324
|
+
|
|
325
|
+
if message is None:
|
|
326
|
+
# The parent closed stdin: it is gone, so the run is stopped and no step will
|
|
327
|
+
# ever be answered.
|
|
328
|
+
self._request_stop(SignalReasons.USER, grace_milliseconds=0)
|
|
329
|
+
|
|
330
|
+
if self._session is not None:
|
|
331
|
+
self._session.fail_pending("The parent closed the executor channel before the step answered.")
|
|
332
|
+
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
if isinstance(message, SubResultMessage):
|
|
336
|
+
session = self._session
|
|
337
|
+
|
|
338
|
+
if session is not None and not session.deliver(message):
|
|
339
|
+
# An unknown or repeated id is a protocol violation (EXECUTOR-PROTOCOL.md 4.1):
|
|
340
|
+
# every waiting call fails rather than trusting the channel any further.
|
|
341
|
+
self._note(f"Protocol violation: sub-result for unknown request {message.request_id!r}.")
|
|
342
|
+
session.fail_pending(f"The parent answered an unknown step request {message.request_id!r}.")
|
|
343
|
+
elif isinstance(message, StopMessage):
|
|
344
|
+
self._outbox.log(LogLevels.INFORMATION, f"Stop requested ({message.reason}).")
|
|
345
|
+
self._request_stop(message.reason, message.grace_period_milliseconds)
|
|
346
|
+
elif isinstance(message, RequestStopMessage):
|
|
347
|
+
# The soft stop: only the flag velophex.should_stop() reads; nothing is canceled.
|
|
348
|
+
session = self._session
|
|
349
|
+
|
|
350
|
+
if session is not None and not session.should_stop:
|
|
351
|
+
self._outbox.log(LogLevels.INFORMATION, "The run was asked to stop when its current work is done.")
|
|
352
|
+
session.request_soft_stop()
|
|
353
|
+
else:
|
|
354
|
+
self._note(f"Ignoring unexpected '{message.type_name}' during a run.")
|
|
355
|
+
|
|
356
|
+
def _request_stop(self, reason: str, grace_milliseconds: int) -> None:
|
|
357
|
+
session = self._session
|
|
358
|
+
|
|
359
|
+
if session is None or session.stop_requested:
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
session.request_stop(reason or SignalReasons.USER)
|
|
363
|
+
|
|
364
|
+
grace = max(grace_milliseconds, 0) / 1000.0
|
|
365
|
+
threading.Timer(grace + _STOP_SLACK_SECONDS, self._grace_expired, args=(grace,)).start()
|
|
366
|
+
|
|
367
|
+
def _grace_expired(self, grace: float) -> None:
|
|
368
|
+
"""Cancel what can be cancelled once the grace period has passed.
|
|
369
|
+
|
|
370
|
+
A synchronous handler cannot be interrupted from outside without corrupting whatever
|
|
371
|
+
it holds open, so the platform's answer is the parent's kill. An ``async`` handler is
|
|
372
|
+
different: cancelling its task unwinds through ``finally`` blocks, so the worker does
|
|
373
|
+
that and lets the handler decide how to end.
|
|
374
|
+
"""
|
|
375
|
+
session = self._session
|
|
376
|
+
|
|
377
|
+
if session is None or not session.stop_requested:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
loop, task = self._loop, self._handler_task
|
|
381
|
+
|
|
382
|
+
if loop is not None and task is not None and not task.done():
|
|
383
|
+
self._outbox.log(
|
|
384
|
+
LogLevels.WARNING,
|
|
385
|
+
f"The grace period of {grace:.0f}s expired; cancelling the handler task.",
|
|
386
|
+
)
|
|
387
|
+
loop.call_soon_threadsafe(task.cancel)
|
|
388
|
+
else:
|
|
389
|
+
self._outbox.log(
|
|
390
|
+
LogLevels.WARNING,
|
|
391
|
+
f"The grace period of {grace:.0f}s expired and the handler is still running; "
|
|
392
|
+
"the parent will end this process.",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------- faults
|
|
396
|
+
|
|
397
|
+
def _fault_from(self, error: BaseException, session: ambient.RunSession) -> ExecutorMessage:
|
|
398
|
+
if isinstance(error, BusinessError):
|
|
399
|
+
return FaultMessage(
|
|
400
|
+
FaultInfo(
|
|
401
|
+
kind=FaultKinds.BUSINESS,
|
|
402
|
+
code=error.code,
|
|
403
|
+
message=error.message,
|
|
404
|
+
detail=error.detail,
|
|
405
|
+
retryable=False,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if isinstance(error, (asyncio.CancelledError, KeyboardInterrupt)) or (
|
|
410
|
+
isinstance(error, SystemExit) and session.stop_requested
|
|
411
|
+
):
|
|
412
|
+
return CanceledMessage(reason=session.stop_reason or SignalReasons.USER)
|
|
413
|
+
|
|
414
|
+
self._note_exception(error)
|
|
415
|
+
|
|
416
|
+
# An error that knows its code (a Runtime API refusal such as ai_budget_exhausted, an
|
|
417
|
+
# agent guardrail) keeps it, with the envelope: it does not collapse to UNHANDLED_EXCEPTION.
|
|
418
|
+
fault = error.to_fault() if isinstance(error, VelophexError) else None
|
|
419
|
+
|
|
420
|
+
if fault is None:
|
|
421
|
+
fault = FaultInfo(
|
|
422
|
+
kind=FaultKinds.APPLICATION,
|
|
423
|
+
code=FaultCodes.UNHANDLED_EXCEPTION,
|
|
424
|
+
message=f"{type(error).__name__}: {error}",
|
|
425
|
+
# The type and message only: the traceback goes to stderr, which the parent
|
|
426
|
+
# captures and redacts.
|
|
427
|
+
detail=type(error).__module__ + "." + type(error).__qualname__,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if getattr(error, "effects_committed", False):
|
|
431
|
+
# The run changed something before it failed (an agent's tool call): running it
|
|
432
|
+
# again would repeat that, so it is never retried automatically.
|
|
433
|
+
fault = replace(
|
|
434
|
+
fault,
|
|
435
|
+
retryable=False,
|
|
436
|
+
version=FAULT_ENVELOPE_VERSION,
|
|
437
|
+
retry_scope=FaultRetryScopes.NONE,
|
|
438
|
+
effects_committed=True,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return FaultMessage(fault)
|
|
442
|
+
|
|
443
|
+
# ---------------------------------------------------------------- terminal delivery
|
|
444
|
+
|
|
445
|
+
def _send_terminal(self, message: ExecutorMessage) -> None:
|
|
446
|
+
"""Send the one terminal message, or a fault that says why it could not be sent.
|
|
447
|
+
|
|
448
|
+
The handler's return value is only turned into JSON here, after the handler has
|
|
449
|
+
finished. Without this check a ``datetime`` in the outputs, a NaN, or outputs over the
|
|
450
|
+
frame limit raised out of the session with no terminal frame at all - which the parent
|
|
451
|
+
can only report as a retryable ``EXECUTOR_CRASHED``, so a run that will fail the same
|
|
452
|
+
way every time was retried. The replacement fault is ``Application`` and not
|
|
453
|
+
retryable, with a stable code, because re-running the same code on the same inputs
|
|
454
|
+
returns the same value.
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
payload = self._outbox.encode(message)
|
|
458
|
+
except ExecutorProtocolException as error:
|
|
459
|
+
payload = self._outbox.encode(
|
|
460
|
+
_undeliverable(
|
|
461
|
+
message,
|
|
462
|
+
FaultCodes.RESULT_TOO_LARGE,
|
|
463
|
+
f"The '{message.type_name}' message is larger than the "
|
|
464
|
+
f"{self._outbox.maximum_payload_bytes}-byte frame limit ({error}).",
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
except (TypeError, ValueError) as error:
|
|
468
|
+
self._note_exception(error)
|
|
469
|
+
payload = self._outbox.encode(
|
|
470
|
+
_undeliverable(
|
|
471
|
+
message,
|
|
472
|
+
FaultCodes.RESULT_NOT_SERIALIZABLE,
|
|
473
|
+
f"The '{message.type_name}' message cannot be written as JSON: {_bounded(str(error))}",
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
self._outbox.send_payload(payload)
|
|
478
|
+
|
|
479
|
+
# ---------------------------------------------------------------- diagnostics
|
|
480
|
+
|
|
481
|
+
def _note(self, message: str) -> None:
|
|
482
|
+
if self._diagnostics is not None:
|
|
483
|
+
print(message, file=self._diagnostics, flush=True)
|
|
484
|
+
|
|
485
|
+
def _note_exception(self, error: BaseException) -> None:
|
|
486
|
+
if self._diagnostics is not None:
|
|
487
|
+
traceback.print_exception(type(error), error, error.__traceback__, file=self._diagnostics)
|
|
488
|
+
self._diagnostics.flush()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
_MAXIMUM_REASON_CHARACTERS = 1024
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _bounded(text: str) -> str:
|
|
495
|
+
return text if len(text) <= _MAXIMUM_REASON_CHARACTERS else text[:_MAXIMUM_REASON_CHARACTERS] + "..."
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _undeliverable(message: ExecutorMessage, code: str, reason: str) -> FaultMessage:
|
|
499
|
+
"""The fault sent in place of a terminal message that cannot be framed.
|
|
500
|
+
|
|
501
|
+
Built only from strings the worker controls (bounded), so it always encodes and always
|
|
502
|
+
fits. A handler's ``result`` becomes a plain output fault; any other terminal message
|
|
503
|
+
(a business fault whose detail is not a string, say) keeps its code in the detail so the
|
|
504
|
+
original outcome is not lost entirely.
|
|
505
|
+
"""
|
|
506
|
+
detail = None
|
|
507
|
+
|
|
508
|
+
if isinstance(message, FaultMessage):
|
|
509
|
+
detail = _bounded(f"Original fault: {message.fault.kind} / {message.fault.code}")
|
|
510
|
+
|
|
511
|
+
return FaultMessage(
|
|
512
|
+
FaultInfo(
|
|
513
|
+
kind=FaultKinds.APPLICATION,
|
|
514
|
+
code=code,
|
|
515
|
+
message=_bounded(reason),
|
|
516
|
+
detail=detail,
|
|
517
|
+
retryable=False,
|
|
518
|
+
)
|
|
519
|
+
)
|