amkernel 0.1.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.
- amkernel/__init__.py +7 -0
- amkernel/__main__.py +5 -0
- amkernel/artifact_store.py +1363 -0
- amkernel/cli.py +172 -0
- amkernel/distribution.py +597 -0
- amkernel/output.py +444 -0
- amkernel/protocol.py +1464 -0
- amkernel/py.typed +0 -0
- amkernel/runner.py +1222 -0
- amkernel/runtime.py +581 -0
- amkernel/server.py +774 -0
- amkernel/skill/SKILL.md +124 -0
- amkernel/skill/references/amkernel-appium.md +46 -0
- amkernel/skill/references/official-skills.json +232 -0
- amkernel/skill/references/official-sources.json +21 -0
- amkernel/skill/scripts/verify_official_skills.py +252 -0
- amkernel/structured_output.py +328 -0
- amkernel/supervisor.py +1453 -0
- amkernel/workspace.py +305 -0
- amkernel-0.1.0.dist-info/METADATA +105 -0
- amkernel-0.1.0.dist-info/RECORD +23 -0
- amkernel-0.1.0.dist-info/WHEEL +4 -0
- amkernel-0.1.0.dist-info/entry_points.txt +3 -0
amkernel/supervisor.py
ADDED
|
@@ -0,0 +1,1453 @@
|
|
|
1
|
+
"""POSIX Python Runner supervision, deadlines, stream capture, and cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Final, Literal, Protocol, cast
|
|
16
|
+
|
|
17
|
+
from amkernel.artifact_store import ArtifactBundle, ArtifactStoreError
|
|
18
|
+
from amkernel.output import OutputObserver, OutputPipeline, OutputResult
|
|
19
|
+
from amkernel.protocol import (
|
|
20
|
+
DIAGNOSTIC_FD_ARGUMENT,
|
|
21
|
+
MAX_CONTROL_FRAME_BYTES,
|
|
22
|
+
REQUEST_FD_ARGUMENT,
|
|
23
|
+
RESPONSE_FD_ARGUMENT,
|
|
24
|
+
DoneResponse,
|
|
25
|
+
ErrorInfo,
|
|
26
|
+
ExecuteRequest,
|
|
27
|
+
ExecutionResponseState,
|
|
28
|
+
InitializeRequest,
|
|
29
|
+
JsonValue,
|
|
30
|
+
ProtocolErrorResponse,
|
|
31
|
+
ProtocolViolation,
|
|
32
|
+
ReadyResponse,
|
|
33
|
+
Response,
|
|
34
|
+
RuntimeDiagnostics,
|
|
35
|
+
ShutdownRequest,
|
|
36
|
+
StartedResponse,
|
|
37
|
+
encode_request,
|
|
38
|
+
parse_response_payload,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
COMMAND_ACK_TIMEOUT: Final = 5.0
|
|
42
|
+
INTERRUPT_GRACE: Final = 1.0
|
|
43
|
+
TERMINATE_GRACE: Final = 1.0
|
|
44
|
+
KILL_GRACE: Final = 1.0
|
|
45
|
+
SHUTDOWN_GRACE: Final = 1.0
|
|
46
|
+
DIAGNOSTIC_TAIL_BYTES: Final = 65_536
|
|
47
|
+
|
|
48
|
+
_READ_CHUNK: Final = 65_536
|
|
49
|
+
_ENVIRONMENT_ALLOWLIST: Final = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"HOME",
|
|
52
|
+
"LANG",
|
|
53
|
+
"LC_ALL",
|
|
54
|
+
"LC_CTYPE",
|
|
55
|
+
"LOGNAME",
|
|
56
|
+
"PATH",
|
|
57
|
+
"SHELL",
|
|
58
|
+
"TERM",
|
|
59
|
+
"TMPDIR",
|
|
60
|
+
"TZ",
|
|
61
|
+
"USER",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
ExecutionStatus = Literal["completed", "failed", "timed_out", "kernel_killed"]
|
|
66
|
+
KernelState = Literal["alive", "dead"]
|
|
67
|
+
SupervisorState = Literal["starting", "alive", "dead", "closing", "closed"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class KernelStartError(RuntimeError):
|
|
71
|
+
"""The Runner failed startup; retained cleanup remains retryable."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
message: str,
|
|
76
|
+
*,
|
|
77
|
+
cleanup: Callable[[], Awaitable[ShutdownResult]] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self._cleanup = cleanup
|
|
81
|
+
self._cleanup_task: asyncio.Task[ShutdownResult] | None = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def cleanup_pending(self) -> bool:
|
|
85
|
+
return self._cleanup is not None
|
|
86
|
+
|
|
87
|
+
async def retry_cleanup(self) -> ShutdownResult:
|
|
88
|
+
"""Retry retained startup cleanup without cancellation orphaning it."""
|
|
89
|
+
cleanup = self._cleanup
|
|
90
|
+
if cleanup is None:
|
|
91
|
+
return ShutdownResult(True)
|
|
92
|
+
if self._cleanup_task is None:
|
|
93
|
+
self._cleanup_task = asyncio.create_task(_run_cleanup_operation(cleanup))
|
|
94
|
+
operation = self._cleanup_task
|
|
95
|
+
caller_cancelled = False
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
result = await asyncio.shield(operation)
|
|
99
|
+
break
|
|
100
|
+
except asyncio.CancelledError:
|
|
101
|
+
caller_cancelled = True
|
|
102
|
+
if result.cleaned:
|
|
103
|
+
self._cleanup = None
|
|
104
|
+
elif self._cleanup_task is operation:
|
|
105
|
+
self._cleanup_task = None
|
|
106
|
+
if caller_cancelled:
|
|
107
|
+
raise asyncio.CancelledError
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class KernelUnavailableError(RuntimeError):
|
|
112
|
+
"""The Supervisor no longer owns a reusable Runner."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ConcurrentExecutionError(RuntimeError):
|
|
116
|
+
"""The Supervisor was asked to run more than one cell at once."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ExecutionAbandoned(RuntimeError):
|
|
120
|
+
"""An internal caller abandoned work before a terminal public outcome."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class SupervisedExecution:
|
|
125
|
+
status: ExecutionStatus
|
|
126
|
+
stdout: str
|
|
127
|
+
stderr: str
|
|
128
|
+
has_result: bool
|
|
129
|
+
result: JsonValue
|
|
130
|
+
error: ErrorInfo | None
|
|
131
|
+
execution_count: int
|
|
132
|
+
duration_ms: int
|
|
133
|
+
kernel_state: KernelState
|
|
134
|
+
runtime: RuntimeDiagnostics
|
|
135
|
+
output: OutputResult
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class ShutdownResult:
|
|
140
|
+
cleaned: bool
|
|
141
|
+
error: str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(slots=True)
|
|
145
|
+
class _StreamTail:
|
|
146
|
+
maximum_bytes: int
|
|
147
|
+
total_bytes: int = 0
|
|
148
|
+
newline_bytes: int = 0
|
|
149
|
+
last_byte: int | None = None
|
|
150
|
+
tail: bytearray = field(default_factory=bytearray)
|
|
151
|
+
|
|
152
|
+
def feed(self, chunk: bytes) -> None:
|
|
153
|
+
if not chunk:
|
|
154
|
+
return
|
|
155
|
+
self.total_bytes += len(chunk)
|
|
156
|
+
self.newline_bytes += chunk.count(b"\n")
|
|
157
|
+
self.last_byte = chunk[-1]
|
|
158
|
+
self.tail.extend(chunk)
|
|
159
|
+
overflow = len(self.tail) - self.maximum_bytes
|
|
160
|
+
if overflow > 0:
|
|
161
|
+
del self.tail[:overflow]
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def total_lines(self) -> int:
|
|
165
|
+
partial = self.total_bytes > 0 and self.last_byte != 0x0A
|
|
166
|
+
return self.newline_bytes + int(partial)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def truncated(self) -> bool:
|
|
170
|
+
return self.total_bytes > len(self.tail)
|
|
171
|
+
|
|
172
|
+
def text(self) -> str:
|
|
173
|
+
value = bytes(self.tail)
|
|
174
|
+
if self.truncated:
|
|
175
|
+
while value and value[0] & 0xC0 == 0x80:
|
|
176
|
+
value = value[1:]
|
|
177
|
+
return value.decode("utf-8", errors="replace")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(slots=True)
|
|
181
|
+
class _ActiveExecution:
|
|
182
|
+
correlation_id: str
|
|
183
|
+
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
184
|
+
started_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
185
|
+
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
186
|
+
interrupt_sent: bool = False
|
|
187
|
+
committed: bool = False
|
|
188
|
+
write_complete: bool = False
|
|
189
|
+
cancellation_time: float | None = None
|
|
190
|
+
|
|
191
|
+
def cancel(self) -> None:
|
|
192
|
+
if self.cancellation_time is None:
|
|
193
|
+
self.cancellation_time = time.monotonic()
|
|
194
|
+
self.cancel_event.set()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True, slots=True)
|
|
198
|
+
class _TerminalObservation:
|
|
199
|
+
response: Response
|
|
200
|
+
completed_at: float
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True, slots=True)
|
|
204
|
+
class _CoreOutcome:
|
|
205
|
+
status: ExecutionStatus
|
|
206
|
+
has_result: bool
|
|
207
|
+
result: JsonValue
|
|
208
|
+
error: ErrorInfo | None
|
|
209
|
+
execution_count: int
|
|
210
|
+
duration_ms: int
|
|
211
|
+
kernel_state: KernelState
|
|
212
|
+
runtime: RuntimeDiagnostics
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _ProcessOperations(Protocol):
|
|
216
|
+
def signal_group(self, process_group: int, signal_number: int) -> None: ...
|
|
217
|
+
|
|
218
|
+
def group_exists(self, process_group: int) -> bool: ...
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _PosixProcessOperations:
|
|
222
|
+
def signal_group(self, process_group: int, signal_number: int) -> None:
|
|
223
|
+
os.killpg(process_group, signal_number)
|
|
224
|
+
|
|
225
|
+
def group_exists(self, process_group: int) -> bool:
|
|
226
|
+
try:
|
|
227
|
+
os.killpg(process_group, 0)
|
|
228
|
+
except ProcessLookupError:
|
|
229
|
+
return False
|
|
230
|
+
except PermissionError:
|
|
231
|
+
return True
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
_POSIX_OPERATIONS = _PosixProcessOperations()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class KernelSupervisor:
|
|
239
|
+
"""Own one Runner process and expose one deep, serialized execution seam."""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
launch_working_directory: Path,
|
|
245
|
+
environment_kind: Literal["project_venv", "active_venv", "system"],
|
|
246
|
+
process: asyncio.subprocess.Process,
|
|
247
|
+
process_group: int,
|
|
248
|
+
request_fd: int,
|
|
249
|
+
response_reader: asyncio.StreamReader,
|
|
250
|
+
response_transport: asyncio.ReadTransport,
|
|
251
|
+
diagnostic_reader: asyncio.StreamReader,
|
|
252
|
+
diagnostic_transport: asyncio.ReadTransport,
|
|
253
|
+
operations: _ProcessOperations,
|
|
254
|
+
) -> None:
|
|
255
|
+
self._launch_working_directory = launch_working_directory
|
|
256
|
+
self._environment_kind = environment_kind
|
|
257
|
+
self._process = process
|
|
258
|
+
self._process_group = process_group
|
|
259
|
+
self._request_fd = request_fd
|
|
260
|
+
self._response_reader = response_reader
|
|
261
|
+
self._response_transport = response_transport
|
|
262
|
+
self._diagnostic_reader = diagnostic_reader
|
|
263
|
+
self._diagnostic_transport = diagnostic_transport
|
|
264
|
+
self._operations = operations
|
|
265
|
+
self._state: SupervisorState = "starting"
|
|
266
|
+
self._runtime = RuntimeDiagnostics(
|
|
267
|
+
executable=os.path.abspath(sys.executable),
|
|
268
|
+
python_version="unknown",
|
|
269
|
+
working_directory=str(launch_working_directory),
|
|
270
|
+
environment_kind=environment_kind,
|
|
271
|
+
)
|
|
272
|
+
self._execution_count = 0
|
|
273
|
+
self._capture: OutputPipeline | None = None
|
|
274
|
+
self._active: _ActiveExecution | None = None
|
|
275
|
+
self._orphan_stdout = _StreamTail(DIAGNOSTIC_TAIL_BYTES)
|
|
276
|
+
self._orphan_stderr = _StreamTail(DIAGNOSTIC_TAIL_BYTES)
|
|
277
|
+
self._diagnostic = _StreamTail(DIAGNOSTIC_TAIL_BYTES)
|
|
278
|
+
self._channels_closed = False
|
|
279
|
+
self._runner_exit_observed = False
|
|
280
|
+
self._process_exit_event = asyncio.Event()
|
|
281
|
+
self._cleanup_error: str | None = None
|
|
282
|
+
self._shutdown_task: asyncio.Task[ShutdownResult] | None = None
|
|
283
|
+
self._idle_response_task: asyncio.Task[None] | None = None
|
|
284
|
+
self._idle_response_read_task: asyncio.Task[Response] | None = None
|
|
285
|
+
self._idle_response_fatal = False
|
|
286
|
+
self._shutdown_result: ShutdownResult | None = None
|
|
287
|
+
self._process_wait_task = asyncio.create_task(process.wait())
|
|
288
|
+
assert process.stdout is not None
|
|
289
|
+
assert process.stderr is not None
|
|
290
|
+
self._reader_tasks = [
|
|
291
|
+
asyncio.create_task(self._drain_user_stream(process.stdout, "stdout")),
|
|
292
|
+
asyncio.create_task(self._drain_user_stream(process.stderr, "stderr")),
|
|
293
|
+
asyncio.create_task(self._drain_diagnostic()),
|
|
294
|
+
]
|
|
295
|
+
self._death_monitor_task = asyncio.create_task(self._observe_process_exit())
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
async def start(
|
|
299
|
+
cls,
|
|
300
|
+
working_directory: Path | None = None,
|
|
301
|
+
*,
|
|
302
|
+
operations: _ProcessOperations = _POSIX_OPERATIONS,
|
|
303
|
+
) -> KernelSupervisor:
|
|
304
|
+
"""Spawn a Runner without allowing caller cancellation to orphan it."""
|
|
305
|
+
operation = asyncio.create_task(cls._start_impl(working_directory, operations=operations))
|
|
306
|
+
caller_cancelled = False
|
|
307
|
+
while True:
|
|
308
|
+
try:
|
|
309
|
+
supervisor = await asyncio.shield(operation)
|
|
310
|
+
break
|
|
311
|
+
except asyncio.CancelledError:
|
|
312
|
+
caller_cancelled = True
|
|
313
|
+
except KernelStartError:
|
|
314
|
+
raise
|
|
315
|
+
except Exception as error:
|
|
316
|
+
raise KernelStartError(str(error)) from error
|
|
317
|
+
if caller_cancelled:
|
|
318
|
+
cleanup = await supervisor.shutdown()
|
|
319
|
+
if not cleanup.cleaned:
|
|
320
|
+
message = cleanup.error or "managed process cleanup failed"
|
|
321
|
+
raise KernelStartError(
|
|
322
|
+
f"startup cancellation cleanup failed: {message}",
|
|
323
|
+
cleanup=supervisor.shutdown,
|
|
324
|
+
)
|
|
325
|
+
raise asyncio.CancelledError
|
|
326
|
+
return supervisor
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
async def _start_impl(
|
|
330
|
+
cls,
|
|
331
|
+
working_directory: Path | None = None,
|
|
332
|
+
*,
|
|
333
|
+
operations: _ProcessOperations = _POSIX_OPERATIONS,
|
|
334
|
+
) -> KernelSupervisor:
|
|
335
|
+
cls._require_supported_platform()
|
|
336
|
+
configured = configured_runtime(working_directory)
|
|
337
|
+
launch_directory = Path(configured.working_directory)
|
|
338
|
+
environment_kind = configured.environment_kind
|
|
339
|
+
environment = _runner_environment()
|
|
340
|
+
|
|
341
|
+
(
|
|
342
|
+
request_read,
|
|
343
|
+
request_write,
|
|
344
|
+
response_read,
|
|
345
|
+
response_write,
|
|
346
|
+
diagnostic_read,
|
|
347
|
+
diagnostic_write,
|
|
348
|
+
) = _create_protocol_pipes()
|
|
349
|
+
child_descriptors = (request_read, response_write, diagnostic_write)
|
|
350
|
+
process: asyncio.subprocess.Process | None = None
|
|
351
|
+
try:
|
|
352
|
+
process = await asyncio.create_subprocess_exec(
|
|
353
|
+
os.path.abspath(sys.executable),
|
|
354
|
+
"-I",
|
|
355
|
+
"-u",
|
|
356
|
+
"-X",
|
|
357
|
+
"utf8",
|
|
358
|
+
"-m",
|
|
359
|
+
"amkernel.runner",
|
|
360
|
+
REQUEST_FD_ARGUMENT,
|
|
361
|
+
str(request_read),
|
|
362
|
+
RESPONSE_FD_ARGUMENT,
|
|
363
|
+
str(response_write),
|
|
364
|
+
DIAGNOSTIC_FD_ARGUMENT,
|
|
365
|
+
str(diagnostic_write),
|
|
366
|
+
cwd=launch_directory,
|
|
367
|
+
env=environment,
|
|
368
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
369
|
+
stdout=asyncio.subprocess.PIPE,
|
|
370
|
+
stderr=asyncio.subprocess.PIPE,
|
|
371
|
+
close_fds=True,
|
|
372
|
+
pass_fds=child_descriptors,
|
|
373
|
+
start_new_session=True,
|
|
374
|
+
)
|
|
375
|
+
finally:
|
|
376
|
+
os.close(request_read)
|
|
377
|
+
os.close(response_write)
|
|
378
|
+
os.close(diagnostic_write)
|
|
379
|
+
if process is None:
|
|
380
|
+
os.close(request_write)
|
|
381
|
+
os.close(response_read)
|
|
382
|
+
os.close(diagnostic_read)
|
|
383
|
+
|
|
384
|
+
response_transport: asyncio.ReadTransport | None = None
|
|
385
|
+
diagnostic_transport: asyncio.ReadTransport | None = None
|
|
386
|
+
supervisor: KernelSupervisor | None = None
|
|
387
|
+
try:
|
|
388
|
+
os.set_blocking(request_write, False)
|
|
389
|
+
response_reader, response_transport = await _stream_reader_from_fd(response_read)
|
|
390
|
+
diagnostic_reader, diagnostic_transport = await _stream_reader_from_fd(diagnostic_read)
|
|
391
|
+
supervisor = cls(
|
|
392
|
+
launch_working_directory=launch_directory,
|
|
393
|
+
environment_kind=environment_kind,
|
|
394
|
+
process=process,
|
|
395
|
+
process_group=process.pid,
|
|
396
|
+
request_fd=request_write,
|
|
397
|
+
response_reader=response_reader,
|
|
398
|
+
response_transport=response_transport,
|
|
399
|
+
diagnostic_reader=diagnostic_reader,
|
|
400
|
+
diagnostic_transport=diagnostic_transport,
|
|
401
|
+
operations=operations,
|
|
402
|
+
)
|
|
403
|
+
await supervisor._initialize()
|
|
404
|
+
return supervisor
|
|
405
|
+
except BaseException as error:
|
|
406
|
+
cleanup_error: str | None
|
|
407
|
+
cleanup_operation: Callable[[], Awaitable[ShutdownResult]] | None = None
|
|
408
|
+
if supervisor is not None:
|
|
409
|
+
cleanup_error = await supervisor._destructive_cleanup()
|
|
410
|
+
supervisor._close_channels()
|
|
411
|
+
if cleanup_error is not None:
|
|
412
|
+
cleanup_operation = supervisor.shutdown
|
|
413
|
+
else:
|
|
414
|
+
_close_fd(request_write)
|
|
415
|
+
if response_transport is not None:
|
|
416
|
+
response_transport.close()
|
|
417
|
+
else:
|
|
418
|
+
_close_fd(response_read)
|
|
419
|
+
if diagnostic_transport is not None:
|
|
420
|
+
diagnostic_transport.close()
|
|
421
|
+
else:
|
|
422
|
+
_close_fd(diagnostic_read)
|
|
423
|
+
cleanup_error = await _cleanup_spawned_process(process, operations)
|
|
424
|
+
if cleanup_error is not None:
|
|
425
|
+
cleanup_operation = _spawned_process_cleanup(process, operations)
|
|
426
|
+
message = str(error)
|
|
427
|
+
if cleanup_error is not None:
|
|
428
|
+
message = f"{message}; cleanup failed: {cleanup_error}"
|
|
429
|
+
raise KernelStartError(message, cleanup=cleanup_operation) from error
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def _require_supported_platform() -> None:
|
|
433
|
+
required = (
|
|
434
|
+
os.name == "posix",
|
|
435
|
+
hasattr(os, "register_at_fork"),
|
|
436
|
+
hasattr(signal, "pthread_sigmask"),
|
|
437
|
+
all(hasattr(signal, name) for name in ("SIGINT", "SIGTERM", "SIGKILL")),
|
|
438
|
+
)
|
|
439
|
+
if not all(required):
|
|
440
|
+
raise RuntimeError("UnsupportedPlatformError: POSIX process primitives are required")
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def state(self) -> SupervisorState:
|
|
444
|
+
return self._state
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def execution_count(self) -> int:
|
|
448
|
+
return self._execution_count
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def runtime(self) -> RuntimeDiagnostics:
|
|
452
|
+
return self._runtime
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def diagnostic_tail(self) -> str:
|
|
456
|
+
return self._diagnostic.text()
|
|
457
|
+
|
|
458
|
+
async def _initialize(self) -> None:
|
|
459
|
+
correlation_id = uuid.uuid4().hex
|
|
460
|
+
request = InitializeRequest(
|
|
461
|
+
correlation_id,
|
|
462
|
+
str(self._launch_working_directory),
|
|
463
|
+
self._environment_kind,
|
|
464
|
+
)
|
|
465
|
+
async with asyncio.timeout(COMMAND_ACK_TIMEOUT):
|
|
466
|
+
await self._write_request(encode_request(request))
|
|
467
|
+
response = await self._next_response()
|
|
468
|
+
if isinstance(response, ProtocolErrorResponse):
|
|
469
|
+
raise ProtocolViolation(
|
|
470
|
+
f"Runner rejected initialize: {response.code}: {response.message}"
|
|
471
|
+
)
|
|
472
|
+
if not isinstance(response, ReadyResponse) or response.correlation_id != correlation_id:
|
|
473
|
+
raise ProtocolViolation("Runner did not return correlated ready")
|
|
474
|
+
expected_executable = os.path.abspath(sys.executable)
|
|
475
|
+
if response.runtime.executable != expected_executable:
|
|
476
|
+
raise ProtocolViolation("Runner executable diagnostic mismatch")
|
|
477
|
+
if response.runtime.working_directory != str(self._launch_working_directory):
|
|
478
|
+
raise ProtocolViolation("Runner working-directory diagnostic mismatch")
|
|
479
|
+
if response.runtime.environment_kind != self._environment_kind:
|
|
480
|
+
raise ProtocolViolation("Runner environment-kind diagnostic mismatch")
|
|
481
|
+
if (
|
|
482
|
+
response.runtime.python_version
|
|
483
|
+
!= configured_runtime(self._launch_working_directory).python_version
|
|
484
|
+
):
|
|
485
|
+
raise ProtocolViolation("Runner Python-version diagnostic mismatch")
|
|
486
|
+
if (
|
|
487
|
+
self._runner_exit_observed
|
|
488
|
+
or self._process.returncode is not None
|
|
489
|
+
or self._process_exit_event.is_set()
|
|
490
|
+
):
|
|
491
|
+
raise OSError("Runner exited during initialization")
|
|
492
|
+
self._runtime = response.runtime
|
|
493
|
+
self._execution_count = 0
|
|
494
|
+
self._state = "alive"
|
|
495
|
+
|
|
496
|
+
async def execute(
|
|
497
|
+
self,
|
|
498
|
+
code: str,
|
|
499
|
+
timeout: float,
|
|
500
|
+
packages: tuple[str, ...] = (),
|
|
501
|
+
*,
|
|
502
|
+
output_bundle: ArtifactBundle,
|
|
503
|
+
observer: OutputObserver | None = None,
|
|
504
|
+
) -> SupervisedExecution:
|
|
505
|
+
"""Execute one cell or return a truthful terminal dead-kernel outcome."""
|
|
506
|
+
if self._state != "alive":
|
|
507
|
+
raise KernelUnavailableError(f"Runner is {self._state}")
|
|
508
|
+
if self._active is not None:
|
|
509
|
+
raise ConcurrentExecutionError("Runner already has an active execution")
|
|
510
|
+
if not (0 < timeout <= 3_600):
|
|
511
|
+
raise ValueError("timeout must be greater than 0 and at most 3600 seconds")
|
|
512
|
+
await self._cancel_idle_response_watch()
|
|
513
|
+
await self._settle_stream_readers()
|
|
514
|
+
if self._state != "alive":
|
|
515
|
+
raise KernelUnavailableError(f"Runner is {self._state}")
|
|
516
|
+
if self._active is not None:
|
|
517
|
+
raise ConcurrentExecutionError("Runner already has an active execution")
|
|
518
|
+
|
|
519
|
+
active = _ActiveExecution(uuid.uuid4().hex)
|
|
520
|
+
try:
|
|
521
|
+
capture = OutputPipeline(output_bundle)
|
|
522
|
+
if observer is not None:
|
|
523
|
+
capture.attach_observer(observer)
|
|
524
|
+
except (ArtifactStoreError, OSError) as error:
|
|
525
|
+
try:
|
|
526
|
+
await output_bundle.abort()
|
|
527
|
+
except ArtifactStoreError:
|
|
528
|
+
pass
|
|
529
|
+
core = await self._fatal_outcome(
|
|
530
|
+
"ArtifactStoreError",
|
|
531
|
+
str(error),
|
|
532
|
+
duration_ms=0,
|
|
533
|
+
)
|
|
534
|
+
self._close_channels()
|
|
535
|
+
return _compose_execution(core, OutputResult.empty())
|
|
536
|
+
self._active = active
|
|
537
|
+
self._capture = capture
|
|
538
|
+
operation = asyncio.create_task(
|
|
539
|
+
self._execute_active(active, capture, code, timeout, packages)
|
|
540
|
+
)
|
|
541
|
+
caller_cancelled = False
|
|
542
|
+
try:
|
|
543
|
+
while True:
|
|
544
|
+
try:
|
|
545
|
+
core = await asyncio.shield(operation)
|
|
546
|
+
break
|
|
547
|
+
except asyncio.CancelledError:
|
|
548
|
+
caller_cancelled = True
|
|
549
|
+
active.cancel()
|
|
550
|
+
finally:
|
|
551
|
+
finalize_task = asyncio.create_task(self._finalize_execution(active, capture))
|
|
552
|
+
while True:
|
|
553
|
+
try:
|
|
554
|
+
output, output_error = await asyncio.shield(finalize_task)
|
|
555
|
+
break
|
|
556
|
+
except asyncio.CancelledError:
|
|
557
|
+
caller_cancelled = True
|
|
558
|
+
active.cancel()
|
|
559
|
+
if output_error is not None:
|
|
560
|
+
core = await self._fatal_outcome(
|
|
561
|
+
"ArtifactStoreError",
|
|
562
|
+
str(output_error),
|
|
563
|
+
duration_ms=core.duration_ms,
|
|
564
|
+
)
|
|
565
|
+
if core.kernel_state == "dead":
|
|
566
|
+
self._close_channels()
|
|
567
|
+
if caller_cancelled:
|
|
568
|
+
raise asyncio.CancelledError
|
|
569
|
+
return _compose_execution(core, output)
|
|
570
|
+
|
|
571
|
+
async def _finalize_execution(
|
|
572
|
+
self,
|
|
573
|
+
active: _ActiveExecution,
|
|
574
|
+
capture: OutputPipeline,
|
|
575
|
+
) -> tuple[OutputResult, ArtifactStoreError | None]:
|
|
576
|
+
await self._settle_stream_readers()
|
|
577
|
+
try:
|
|
578
|
+
output = await capture.finish()
|
|
579
|
+
output_error = None
|
|
580
|
+
except ArtifactStoreError as error:
|
|
581
|
+
output = capture.snapshot_result()
|
|
582
|
+
output_error = error
|
|
583
|
+
self._capture = None
|
|
584
|
+
self._active = None
|
|
585
|
+
active.done_event.set()
|
|
586
|
+
return output, output_error
|
|
587
|
+
|
|
588
|
+
async def _execute_active(
|
|
589
|
+
self,
|
|
590
|
+
active: _ActiveExecution,
|
|
591
|
+
capture: OutputPipeline,
|
|
592
|
+
code: str,
|
|
593
|
+
timeout: float,
|
|
594
|
+
packages: tuple[str, ...],
|
|
595
|
+
) -> _CoreOutcome:
|
|
596
|
+
delivery_task = asyncio.create_task(self._deliver_execute(active, code, packages))
|
|
597
|
+
delivery_cancel_task = asyncio.create_task(active.cancel_event.wait())
|
|
598
|
+
try:
|
|
599
|
+
async with asyncio.timeout(COMMAND_ACK_TIMEOUT):
|
|
600
|
+
await asyncio.wait(
|
|
601
|
+
{delivery_task, delivery_cancel_task},
|
|
602
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
603
|
+
)
|
|
604
|
+
if delivery_cancel_task.done():
|
|
605
|
+
if not active.committed:
|
|
606
|
+
await _cancel_tasks(delivery_task, delivery_cancel_task)
|
|
607
|
+
return _CoreOutcome(
|
|
608
|
+
"timed_out",
|
|
609
|
+
False,
|
|
610
|
+
None,
|
|
611
|
+
ErrorInfo("CancelledError", "cancelled before execution", ()),
|
|
612
|
+
self._execution_count,
|
|
613
|
+
0,
|
|
614
|
+
"alive",
|
|
615
|
+
self._runtime,
|
|
616
|
+
)
|
|
617
|
+
if not active.write_complete:
|
|
618
|
+
await _cancel_tasks(delivery_task, delivery_cancel_task)
|
|
619
|
+
return await self._fatal_outcome(
|
|
620
|
+
"RunnerProtocolTimeout",
|
|
621
|
+
"cancellation interrupted request delivery",
|
|
622
|
+
duration_ms=0,
|
|
623
|
+
)
|
|
624
|
+
response = await delivery_task
|
|
625
|
+
except TimeoutError as error:
|
|
626
|
+
await _cancel_tasks(delivery_task, delivery_cancel_task)
|
|
627
|
+
return await self._fatal_outcome(
|
|
628
|
+
"RunnerProtocolTimeout",
|
|
629
|
+
f"execute delivery/ack failed: {error}",
|
|
630
|
+
duration_ms=0,
|
|
631
|
+
)
|
|
632
|
+
except ProtocolViolation as error:
|
|
633
|
+
await _cancel_tasks(delivery_task, delivery_cancel_task)
|
|
634
|
+
return await self._fatal_outcome(
|
|
635
|
+
"RunnerProtocolError",
|
|
636
|
+
f"execute delivery/ack failed: {error}",
|
|
637
|
+
duration_ms=0,
|
|
638
|
+
)
|
|
639
|
+
except OSError as error:
|
|
640
|
+
await _cancel_tasks(delivery_task, delivery_cancel_task)
|
|
641
|
+
return await self._fatal_outcome(
|
|
642
|
+
"KernelExited",
|
|
643
|
+
f"execute delivery/ack failed: {error}",
|
|
644
|
+
duration_ms=0,
|
|
645
|
+
)
|
|
646
|
+
finally:
|
|
647
|
+
await _cancel_tasks(delivery_cancel_task)
|
|
648
|
+
|
|
649
|
+
if isinstance(response, ProtocolErrorResponse):
|
|
650
|
+
return await self._fatal_outcome(
|
|
651
|
+
_protocol_error_type(response),
|
|
652
|
+
f"{response.code}: {response.message}",
|
|
653
|
+
duration_ms=0,
|
|
654
|
+
)
|
|
655
|
+
if (
|
|
656
|
+
not isinstance(response, StartedResponse)
|
|
657
|
+
or response.correlation_id != active.correlation_id
|
|
658
|
+
or response.execution_count != self._execution_count + 1
|
|
659
|
+
):
|
|
660
|
+
return await self._fatal_outcome(
|
|
661
|
+
"RunnerProtocolError",
|
|
662
|
+
"invalid or uncorrelated started response",
|
|
663
|
+
duration_ms=0,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
self._execution_count = response.execution_count
|
|
667
|
+
active.started_event.set()
|
|
668
|
+
started = time.monotonic()
|
|
669
|
+
response_task = asyncio.create_task(self._await_terminal(active))
|
|
670
|
+
cancel_task = asyncio.create_task(active.cancel_event.wait())
|
|
671
|
+
timeout_task = asyncio.create_task(_deadline_after(timeout))
|
|
672
|
+
output_limit_task = asyncio.create_task(capture.limit_event.wait())
|
|
673
|
+
output_failure_task = asyncio.create_task(capture.failure_event.wait())
|
|
674
|
+
intervention_tasks = (
|
|
675
|
+
timeout_task,
|
|
676
|
+
cancel_task,
|
|
677
|
+
output_limit_task,
|
|
678
|
+
output_failure_task,
|
|
679
|
+
)
|
|
680
|
+
current_batch: set[asyncio.Task[object]] = set()
|
|
681
|
+
committed_batch: frozenset[asyncio.Task[object]] = frozenset()
|
|
682
|
+
commit_handle: asyncio.Handle | None = None
|
|
683
|
+
|
|
684
|
+
def commit_batch() -> None:
|
|
685
|
+
nonlocal committed_batch, commit_handle
|
|
686
|
+
committed_batch = frozenset(current_batch)
|
|
687
|
+
current_batch.clear()
|
|
688
|
+
commit_handle = None
|
|
689
|
+
|
|
690
|
+
def record_ready(task: asyncio.Task[object]) -> None:
|
|
691
|
+
nonlocal commit_handle
|
|
692
|
+
current_batch.add(task)
|
|
693
|
+
if commit_handle is None:
|
|
694
|
+
commit_handle = asyncio.get_running_loop().call_soon(commit_batch)
|
|
695
|
+
|
|
696
|
+
for intervention_task in intervention_tasks:
|
|
697
|
+
intervention_task.add_done_callback(record_ready)
|
|
698
|
+
await asyncio.wait(
|
|
699
|
+
{response_task, *intervention_tasks},
|
|
700
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
701
|
+
)
|
|
702
|
+
if any(task.done() for task in intervention_tasks):
|
|
703
|
+
observation: _TerminalObservation | None = None
|
|
704
|
+
terminal: Response | None = None
|
|
705
|
+
if response_task.done():
|
|
706
|
+
try:
|
|
707
|
+
observation = response_task.result()
|
|
708
|
+
terminal = observation.response
|
|
709
|
+
except ProtocolViolation as error:
|
|
710
|
+
await _cancel_tasks(response_task, *intervention_tasks)
|
|
711
|
+
return await self._fatal_outcome(
|
|
712
|
+
"RunnerProtocolError",
|
|
713
|
+
f"response channel failed: {error}",
|
|
714
|
+
duration_ms=_duration_ms(started),
|
|
715
|
+
started=started,
|
|
716
|
+
)
|
|
717
|
+
except OSError as error:
|
|
718
|
+
await _cancel_tasks(response_task, *intervention_tasks)
|
|
719
|
+
return await self._fatal_outcome(
|
|
720
|
+
"KernelExited",
|
|
721
|
+
f"response channel failed: {error}",
|
|
722
|
+
duration_ms=_duration_ms(started),
|
|
723
|
+
started=started,
|
|
724
|
+
)
|
|
725
|
+
readiness_batch = committed_batch or frozenset(current_batch)
|
|
726
|
+
intervention_time, reason = _select_intervention(
|
|
727
|
+
deadline=timeout_task.result() if timeout_task.done() else None,
|
|
728
|
+
output_limit=capture.limit_time if output_limit_task.done() else None,
|
|
729
|
+
cancellation=active.cancellation_time if cancel_task.done() else None,
|
|
730
|
+
output_failure=(capture.failure_time if output_failure_task.done() else None),
|
|
731
|
+
deadline_and_limit_same_turn=(
|
|
732
|
+
timeout_task in readiness_batch and output_limit_task in readiness_batch
|
|
733
|
+
),
|
|
734
|
+
)
|
|
735
|
+
for intervention_task in intervention_tasks:
|
|
736
|
+
intervention_task.remove_done_callback(record_ready)
|
|
737
|
+
await _cancel_tasks(*intervention_tasks)
|
|
738
|
+
if observation is not None and observation.completed_at <= intervention_time:
|
|
739
|
+
return await self._normal_terminal(active, observation.response, started)
|
|
740
|
+
if reason == "output failure":
|
|
741
|
+
await _cancel_tasks(response_task)
|
|
742
|
+
failure = capture.failure
|
|
743
|
+
return await self._fatal_outcome(
|
|
744
|
+
"ArtifactStoreError",
|
|
745
|
+
str(failure or "execution output storage failed"),
|
|
746
|
+
duration_ms=_duration_ms(started),
|
|
747
|
+
started=started,
|
|
748
|
+
)
|
|
749
|
+
return await self._interrupt_or_escalate(
|
|
750
|
+
active,
|
|
751
|
+
started,
|
|
752
|
+
reason,
|
|
753
|
+
terminal,
|
|
754
|
+
terminal_task=response_task if terminal is None else None,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
for intervention_task in intervention_tasks:
|
|
758
|
+
intervention_task.remove_done_callback(record_ready)
|
|
759
|
+
await _cancel_tasks(*intervention_tasks)
|
|
760
|
+
try:
|
|
761
|
+
terminal = response_task.result().response
|
|
762
|
+
except ProtocolViolation as error:
|
|
763
|
+
return await self._fatal_outcome(
|
|
764
|
+
"RunnerProtocolError",
|
|
765
|
+
f"response channel failed: {error}",
|
|
766
|
+
duration_ms=_duration_ms(started),
|
|
767
|
+
started=started,
|
|
768
|
+
)
|
|
769
|
+
except OSError as error:
|
|
770
|
+
return await self._fatal_outcome(
|
|
771
|
+
"KernelExited",
|
|
772
|
+
f"response channel failed: {error}",
|
|
773
|
+
duration_ms=_duration_ms(started),
|
|
774
|
+
started=started,
|
|
775
|
+
)
|
|
776
|
+
return await self._normal_terminal(active, terminal, started)
|
|
777
|
+
|
|
778
|
+
async def _deliver_execute(
|
|
779
|
+
self, active: _ActiveExecution, code: str, packages: tuple[str, ...]
|
|
780
|
+
) -> Response:
|
|
781
|
+
await self._write_request(
|
|
782
|
+
encode_request(ExecuteRequest(active.correlation_id, code, packages)),
|
|
783
|
+
active=active,
|
|
784
|
+
)
|
|
785
|
+
return await self._next_response()
|
|
786
|
+
|
|
787
|
+
async def _await_terminal(self, active: _ActiveExecution) -> _TerminalObservation:
|
|
788
|
+
state = ExecutionResponseState(active.correlation_id, self._execution_count)
|
|
789
|
+
while True:
|
|
790
|
+
response = await self._next_response()
|
|
791
|
+
state.accept(response)
|
|
792
|
+
if isinstance(response, (DoneResponse, ProtocolErrorResponse)):
|
|
793
|
+
await _settle_ready_stream_readers()
|
|
794
|
+
self._capture = None
|
|
795
|
+
return _TerminalObservation(response, time.monotonic())
|
|
796
|
+
capture = self._capture
|
|
797
|
+
if capture is None:
|
|
798
|
+
raise ProtocolViolation("output event observed without active capture")
|
|
799
|
+
capture.accept_response(response)
|
|
800
|
+
|
|
801
|
+
async def _normal_terminal(
|
|
802
|
+
self,
|
|
803
|
+
active: _ActiveExecution,
|
|
804
|
+
response: Response,
|
|
805
|
+
started: float,
|
|
806
|
+
) -> _CoreOutcome:
|
|
807
|
+
if not isinstance(response, DoneResponse):
|
|
808
|
+
if isinstance(response, ProtocolErrorResponse):
|
|
809
|
+
error_type = _protocol_error_type(response)
|
|
810
|
+
message = f"{response.code}: {response.message}"
|
|
811
|
+
else:
|
|
812
|
+
error_type = "RunnerProtocolError"
|
|
813
|
+
message = "unexpected terminal response"
|
|
814
|
+
return await self._fatal_outcome(
|
|
815
|
+
error_type,
|
|
816
|
+
message,
|
|
817
|
+
duration_ms=_duration_ms(started),
|
|
818
|
+
started=started,
|
|
819
|
+
)
|
|
820
|
+
if not self._valid_done(active, response):
|
|
821
|
+
return await self._fatal_outcome(
|
|
822
|
+
"RunnerProtocolError",
|
|
823
|
+
"invalid or uncorrelated done response",
|
|
824
|
+
duration_ms=_duration_ms(started),
|
|
825
|
+
started=started,
|
|
826
|
+
)
|
|
827
|
+
self._runtime = response.runtime
|
|
828
|
+
self._ensure_idle_response_watch()
|
|
829
|
+
if response.outcome == "completed":
|
|
830
|
+
return _CoreOutcome(
|
|
831
|
+
"completed",
|
|
832
|
+
response.has_result,
|
|
833
|
+
response.result,
|
|
834
|
+
None,
|
|
835
|
+
response.execution_count,
|
|
836
|
+
_duration_ms(started),
|
|
837
|
+
"alive",
|
|
838
|
+
response.runtime,
|
|
839
|
+
)
|
|
840
|
+
error = response.error or ErrorInfo("RunnerError", "missing error", ())
|
|
841
|
+
return _CoreOutcome(
|
|
842
|
+
"failed",
|
|
843
|
+
False,
|
|
844
|
+
None,
|
|
845
|
+
error,
|
|
846
|
+
response.execution_count,
|
|
847
|
+
_duration_ms(started),
|
|
848
|
+
"alive",
|
|
849
|
+
response.runtime,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
async def _interrupt_or_escalate(
|
|
853
|
+
self,
|
|
854
|
+
active: _ActiveExecution,
|
|
855
|
+
started: float,
|
|
856
|
+
reason: str,
|
|
857
|
+
terminal: Response | None = None,
|
|
858
|
+
*,
|
|
859
|
+
terminal_task: asyncio.Task[_TerminalObservation] | None = None,
|
|
860
|
+
) -> _CoreOutcome:
|
|
861
|
+
try:
|
|
862
|
+
self._operations.signal_group(self._process_group, signal.SIGINT)
|
|
863
|
+
except ProcessLookupError:
|
|
864
|
+
if terminal_task is not None:
|
|
865
|
+
await _cancel_tasks(terminal_task)
|
|
866
|
+
return await self._fatal_outcome(
|
|
867
|
+
"KernelExited",
|
|
868
|
+
f"Runner exited before {reason} interrupt",
|
|
869
|
+
duration_ms=_duration_ms(started),
|
|
870
|
+
started=started,
|
|
871
|
+
)
|
|
872
|
+
except Exception as error:
|
|
873
|
+
if terminal_task is not None:
|
|
874
|
+
await _cancel_tasks(terminal_task)
|
|
875
|
+
return await self._fatal_outcome(
|
|
876
|
+
"CleanupError",
|
|
877
|
+
f"failed to signal Runner for {reason}: {type(error).__name__}: {error}",
|
|
878
|
+
duration_ms=_duration_ms(started),
|
|
879
|
+
started=started,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
if terminal is None:
|
|
883
|
+
try:
|
|
884
|
+
async with asyncio.timeout(INTERRUPT_GRACE):
|
|
885
|
+
if terminal_task is not None:
|
|
886
|
+
response = (await terminal_task).response
|
|
887
|
+
else:
|
|
888
|
+
response = (await self._await_terminal(active)).response
|
|
889
|
+
except ProtocolViolation as error:
|
|
890
|
+
return await self._fatal_outcome(
|
|
891
|
+
"RunnerProtocolError",
|
|
892
|
+
f"invalid response after {reason} interrupt: {error}",
|
|
893
|
+
duration_ms=_duration_ms(started),
|
|
894
|
+
started=started,
|
|
895
|
+
)
|
|
896
|
+
except (TimeoutError, OSError):
|
|
897
|
+
return await self._fatal_outcome(
|
|
898
|
+
"KernelKilled",
|
|
899
|
+
f"Runner did not settle after {reason} interrupt",
|
|
900
|
+
duration_ms=_duration_ms(started),
|
|
901
|
+
started=started,
|
|
902
|
+
)
|
|
903
|
+
else:
|
|
904
|
+
response = terminal
|
|
905
|
+
|
|
906
|
+
if isinstance(response, DoneResponse) and self._valid_done(active, response):
|
|
907
|
+
self._runtime = response.runtime
|
|
908
|
+
self._ensure_idle_response_watch()
|
|
909
|
+
output_limited = reason == "output limit"
|
|
910
|
+
return _CoreOutcome(
|
|
911
|
+
"failed" if output_limited else "timed_out",
|
|
912
|
+
False,
|
|
913
|
+
None,
|
|
914
|
+
ErrorInfo(
|
|
915
|
+
"OutputLimitExceeded" if output_limited else "TimeoutError",
|
|
916
|
+
"output limit exceeded" if output_limited else f"cell interrupted by {reason}",
|
|
917
|
+
(),
|
|
918
|
+
),
|
|
919
|
+
response.execution_count,
|
|
920
|
+
_duration_ms(started),
|
|
921
|
+
"alive",
|
|
922
|
+
response.runtime,
|
|
923
|
+
)
|
|
924
|
+
if isinstance(response, ProtocolErrorResponse):
|
|
925
|
+
error_type = _protocol_error_type(response)
|
|
926
|
+
message = f"{response.code}: {response.message}"
|
|
927
|
+
else:
|
|
928
|
+
error_type = "RunnerProtocolError"
|
|
929
|
+
message = "invalid terminal response after interrupt"
|
|
930
|
+
return await self._fatal_outcome(
|
|
931
|
+
error_type,
|
|
932
|
+
message,
|
|
933
|
+
duration_ms=_duration_ms(started),
|
|
934
|
+
started=started,
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def _valid_done(self, active: _ActiveExecution, response: DoneResponse) -> bool:
|
|
938
|
+
return (
|
|
939
|
+
response.correlation_id == active.correlation_id
|
|
940
|
+
and response.execution_count == self._execution_count
|
|
941
|
+
and response.runtime.executable == self._runtime.executable
|
|
942
|
+
and response.runtime.python_version == self._runtime.python_version
|
|
943
|
+
and response.runtime.environment_kind == self._runtime.environment_kind
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
async def cancel_active(self) -> KernelState:
|
|
947
|
+
"""Interrupt active work and wait until its state is stable."""
|
|
948
|
+
active = self._active
|
|
949
|
+
if active is None:
|
|
950
|
+
return "alive" if self._state == "alive" else "dead"
|
|
951
|
+
started_task = asyncio.create_task(active.started_event.wait())
|
|
952
|
+
done_task = asyncio.create_task(active.done_event.wait())
|
|
953
|
+
await asyncio.wait({started_task, done_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
954
|
+
await _cancel_tasks(started_task, done_task)
|
|
955
|
+
if active.done_event.is_set():
|
|
956
|
+
return "alive" if self._state == "alive" else "dead"
|
|
957
|
+
active.cancel()
|
|
958
|
+
await active.done_event.wait()
|
|
959
|
+
return "alive" if self._state == "alive" else "dead"
|
|
960
|
+
|
|
961
|
+
async def shutdown(self) -> ShutdownResult:
|
|
962
|
+
"""Close intake and remove the complete managed process group."""
|
|
963
|
+
if self._state == "closed" and self._shutdown_result is not None:
|
|
964
|
+
return self._shutdown_result
|
|
965
|
+
if self._shutdown_task is None:
|
|
966
|
+
self._shutdown_task = asyncio.create_task(self._shutdown_impl())
|
|
967
|
+
operation = self._shutdown_task
|
|
968
|
+
caller_cancelled = False
|
|
969
|
+
while True:
|
|
970
|
+
try:
|
|
971
|
+
result = await asyncio.shield(operation)
|
|
972
|
+
break
|
|
973
|
+
except asyncio.CancelledError:
|
|
974
|
+
caller_cancelled = True
|
|
975
|
+
if self._state == "closed":
|
|
976
|
+
self._shutdown_result = result
|
|
977
|
+
elif not result.cleaned and self._shutdown_task is operation:
|
|
978
|
+
self._shutdown_task = None
|
|
979
|
+
if caller_cancelled:
|
|
980
|
+
raise asyncio.CancelledError
|
|
981
|
+
return result
|
|
982
|
+
|
|
983
|
+
async def _shutdown_impl(self) -> ShutdownResult:
|
|
984
|
+
if self._state == "closed":
|
|
985
|
+
return ShutdownResult(True)
|
|
986
|
+
await self._cancel_idle_response_watch()
|
|
987
|
+
self._state = "closing"
|
|
988
|
+
active = self._active
|
|
989
|
+
if active is not None:
|
|
990
|
+
active.cancel()
|
|
991
|
+
await active.done_event.wait()
|
|
992
|
+
graceful_error: str | None = None
|
|
993
|
+
if self._process.returncode is None and not self._channels_closed:
|
|
994
|
+
try:
|
|
995
|
+
correlation_id = uuid.uuid4().hex
|
|
996
|
+
async with asyncio.timeout(SHUTDOWN_GRACE):
|
|
997
|
+
await self._write_request(encode_request(ShutdownRequest(correlation_id)))
|
|
998
|
+
return_code = await asyncio.shield(self._process_wait_task)
|
|
999
|
+
if return_code != 0:
|
|
1000
|
+
graceful_error = f"Runner graceful shutdown exited with status {return_code}"
|
|
1001
|
+
except (TimeoutError, OSError, ProtocolViolation):
|
|
1002
|
+
pass
|
|
1003
|
+
cleanup_error = await self._escalate_group()
|
|
1004
|
+
final_error = cleanup_error or graceful_error
|
|
1005
|
+
self._state = "closed" if cleanup_error is None else "dead"
|
|
1006
|
+
self._close_channels()
|
|
1007
|
+
return ShutdownResult(final_error is None, final_error)
|
|
1008
|
+
|
|
1009
|
+
async def _fatal_outcome(
|
|
1010
|
+
self,
|
|
1011
|
+
error_type: str,
|
|
1012
|
+
message: str,
|
|
1013
|
+
*,
|
|
1014
|
+
duration_ms: int,
|
|
1015
|
+
started: float | None = None,
|
|
1016
|
+
) -> _CoreOutcome:
|
|
1017
|
+
self._diagnostic.feed(f"{error_type}: {message}\n".encode("utf-8", errors="replace"))
|
|
1018
|
+
cleanup_error = await self._destructive_cleanup()
|
|
1019
|
+
if started is not None:
|
|
1020
|
+
duration_ms = _duration_ms(started)
|
|
1021
|
+
if cleanup_error is not None:
|
|
1022
|
+
error_type = "CleanupError"
|
|
1023
|
+
return _CoreOutcome(
|
|
1024
|
+
"kernel_killed",
|
|
1025
|
+
False,
|
|
1026
|
+
None,
|
|
1027
|
+
ErrorInfo(error_type, _public_infrastructure_message(error_type), ()),
|
|
1028
|
+
self._execution_count,
|
|
1029
|
+
duration_ms,
|
|
1030
|
+
"dead",
|
|
1031
|
+
self._runtime,
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
async def _destructive_cleanup(self) -> str | None:
|
|
1035
|
+
self._state = "dead"
|
|
1036
|
+
cleanup_error = await self._escalate_group()
|
|
1037
|
+
if cleanup_error is not None:
|
|
1038
|
+
self._cleanup_error = cleanup_error
|
|
1039
|
+
self._diagnostic.feed(f"cleanup failed: {cleanup_error}\n".encode())
|
|
1040
|
+
return cleanup_error
|
|
1041
|
+
|
|
1042
|
+
async def _escalate_group(self) -> str | None:
|
|
1043
|
+
try:
|
|
1044
|
+
return await self._escalate_group_unchecked()
|
|
1045
|
+
except Exception as error:
|
|
1046
|
+
return f"process-group operation failed: {type(error).__name__}: {error}"
|
|
1047
|
+
|
|
1048
|
+
async def _escalate_group_unchecked(self) -> str | None:
|
|
1049
|
+
if not self._operations.group_exists(self._process_group):
|
|
1050
|
+
await self._await_process_exit(0.1)
|
|
1051
|
+
return None
|
|
1052
|
+
try:
|
|
1053
|
+
self._operations.signal_group(self._process_group, signal.SIGTERM)
|
|
1054
|
+
except ProcessLookupError:
|
|
1055
|
+
if not self._operations.group_exists(self._process_group):
|
|
1056
|
+
await self._await_process_exit(0.1)
|
|
1057
|
+
return None
|
|
1058
|
+
if await self._wait_group_gone(TERMINATE_GRACE):
|
|
1059
|
+
await self._await_process_exit(0.1)
|
|
1060
|
+
return None
|
|
1061
|
+
try:
|
|
1062
|
+
self._operations.signal_group(self._process_group, signal.SIGKILL)
|
|
1063
|
+
except ProcessLookupError:
|
|
1064
|
+
if not self._operations.group_exists(self._process_group):
|
|
1065
|
+
await self._await_process_exit(0.1)
|
|
1066
|
+
return None
|
|
1067
|
+
if await self._wait_group_gone(KILL_GRACE):
|
|
1068
|
+
await self._await_process_exit(0.1)
|
|
1069
|
+
return None
|
|
1070
|
+
return "managed process group remains after SIGKILL"
|
|
1071
|
+
|
|
1072
|
+
async def _wait_group_gone(self, timeout: float) -> bool:
|
|
1073
|
+
deadline = time.monotonic() + timeout
|
|
1074
|
+
while time.monotonic() < deadline:
|
|
1075
|
+
if not self._operations.group_exists(self._process_group):
|
|
1076
|
+
return True
|
|
1077
|
+
await asyncio.sleep(0.01)
|
|
1078
|
+
return not self._operations.group_exists(self._process_group)
|
|
1079
|
+
|
|
1080
|
+
async def _await_process_exit(self, timeout: float) -> None:
|
|
1081
|
+
if self._process_wait_task.done():
|
|
1082
|
+
return
|
|
1083
|
+
try:
|
|
1084
|
+
async with asyncio.timeout(timeout):
|
|
1085
|
+
await asyncio.shield(self._process_wait_task)
|
|
1086
|
+
except TimeoutError:
|
|
1087
|
+
pass
|
|
1088
|
+
|
|
1089
|
+
async def _observe_process_exit(self) -> None:
|
|
1090
|
+
while self._process.returncode is None:
|
|
1091
|
+
await asyncio.sleep(0.01)
|
|
1092
|
+
self._runner_exit_observed = True
|
|
1093
|
+
self._process_exit_event.set()
|
|
1094
|
+
active = self._active
|
|
1095
|
+
if active is not None:
|
|
1096
|
+
await active.done_event.wait()
|
|
1097
|
+
if self._state == "alive":
|
|
1098
|
+
self._state = "dead"
|
|
1099
|
+
cleanup_error = await self._escalate_group()
|
|
1100
|
+
if cleanup_error is not None:
|
|
1101
|
+
self._cleanup_error = cleanup_error
|
|
1102
|
+
self._diagnostic.feed(f"cleanup failed: {cleanup_error}\n".encode())
|
|
1103
|
+
self._close_channels()
|
|
1104
|
+
|
|
1105
|
+
async def _write_request(
|
|
1106
|
+
self,
|
|
1107
|
+
frame: bytes,
|
|
1108
|
+
*,
|
|
1109
|
+
active: _ActiveExecution | None = None,
|
|
1110
|
+
) -> None:
|
|
1111
|
+
view = memoryview(frame)
|
|
1112
|
+
loop = asyncio.get_running_loop()
|
|
1113
|
+
while view:
|
|
1114
|
+
if active is not None and not active.committed:
|
|
1115
|
+
active.committed = True
|
|
1116
|
+
try:
|
|
1117
|
+
written = os.write(self._request_fd, view)
|
|
1118
|
+
except InterruptedError:
|
|
1119
|
+
continue
|
|
1120
|
+
except BlockingIOError:
|
|
1121
|
+
ready = loop.create_future()
|
|
1122
|
+
loop.add_writer(self._request_fd, _resolve_future, ready)
|
|
1123
|
+
try:
|
|
1124
|
+
await ready
|
|
1125
|
+
finally:
|
|
1126
|
+
loop.remove_writer(self._request_fd)
|
|
1127
|
+
continue
|
|
1128
|
+
if written <= 0:
|
|
1129
|
+
raise BrokenPipeError("Runner request pipe accepted no bytes")
|
|
1130
|
+
view = view[written:]
|
|
1131
|
+
if active is not None:
|
|
1132
|
+
active.write_complete = True
|
|
1133
|
+
|
|
1134
|
+
def _ensure_idle_response_watch(self) -> None:
|
|
1135
|
+
task = self._idle_response_task
|
|
1136
|
+
if task is not None and not task.done():
|
|
1137
|
+
return
|
|
1138
|
+
read_task = asyncio.create_task(self._read_response())
|
|
1139
|
+
self._idle_response_read_task = read_task
|
|
1140
|
+
self._idle_response_fatal = False
|
|
1141
|
+
self._idle_response_task = asyncio.create_task(
|
|
1142
|
+
self._watch_post_terminal_response(read_task)
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
async def _watch_post_terminal_response(self, read_task: asyncio.Task[Response]) -> None:
|
|
1146
|
+
try:
|
|
1147
|
+
response = await read_task
|
|
1148
|
+
except asyncio.CancelledError:
|
|
1149
|
+
raise
|
|
1150
|
+
except OSError:
|
|
1151
|
+
return
|
|
1152
|
+
except ProtocolViolation as error:
|
|
1153
|
+
message = str(error)
|
|
1154
|
+
else:
|
|
1155
|
+
message = f"response after terminal frame: {type(response).__name__}"
|
|
1156
|
+
self._idle_response_fatal = True
|
|
1157
|
+
self._diagnostic.feed(f"RunnerProtocolError: {message}\n".encode())
|
|
1158
|
+
await self._destructive_cleanup()
|
|
1159
|
+
|
|
1160
|
+
async def _cancel_idle_response_watch(self) -> None:
|
|
1161
|
+
watcher = self._idle_response_task
|
|
1162
|
+
read_task = self._idle_response_read_task
|
|
1163
|
+
if watcher is None:
|
|
1164
|
+
return
|
|
1165
|
+
if self._idle_response_fatal or (read_task is not None and read_task.done()):
|
|
1166
|
+
await asyncio.shield(watcher)
|
|
1167
|
+
else:
|
|
1168
|
+
if read_task is not None:
|
|
1169
|
+
read_task.cancel()
|
|
1170
|
+
watcher.cancel()
|
|
1171
|
+
await asyncio.gather(
|
|
1172
|
+
*(task for task in (read_task, watcher) if task is not None),
|
|
1173
|
+
return_exceptions=True,
|
|
1174
|
+
)
|
|
1175
|
+
self._idle_response_task = None
|
|
1176
|
+
self._idle_response_read_task = None
|
|
1177
|
+
self._idle_response_fatal = False
|
|
1178
|
+
|
|
1179
|
+
async def _next_response(self) -> Response:
|
|
1180
|
+
response_task = asyncio.create_task(self._read_response())
|
|
1181
|
+
exit_task = asyncio.create_task(self._process_exit_event.wait())
|
|
1182
|
+
try:
|
|
1183
|
+
await asyncio.wait(
|
|
1184
|
+
{response_task, exit_task},
|
|
1185
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1186
|
+
)
|
|
1187
|
+
if response_task.done():
|
|
1188
|
+
response = response_task.result()
|
|
1189
|
+
if (exit_task.done() or self._process.returncode is not None) and not isinstance(
|
|
1190
|
+
response, ProtocolErrorResponse
|
|
1191
|
+
):
|
|
1192
|
+
raise OSError(
|
|
1193
|
+
f"Runner exited with code {self._process.returncode} after response"
|
|
1194
|
+
)
|
|
1195
|
+
return response
|
|
1196
|
+
raise OSError(f"Runner exited with code {self._process.returncode}")
|
|
1197
|
+
finally:
|
|
1198
|
+
await _cancel_tasks(response_task, exit_task)
|
|
1199
|
+
|
|
1200
|
+
async def _read_response(self) -> Response:
|
|
1201
|
+
try:
|
|
1202
|
+
prefix = await self._response_reader.readexactly(4)
|
|
1203
|
+
length = int.from_bytes(prefix, "big")
|
|
1204
|
+
if length == 0 or length > MAX_CONTROL_FRAME_BYTES:
|
|
1205
|
+
raise ProtocolViolation(f"invalid response frame length: {length}")
|
|
1206
|
+
payload = await self._response_reader.readexactly(length)
|
|
1207
|
+
except asyncio.IncompleteReadError as error:
|
|
1208
|
+
raise OSError("Runner response channel closed") from error
|
|
1209
|
+
return parse_response_payload(payload)
|
|
1210
|
+
|
|
1211
|
+
async def _drain_user_stream(
|
|
1212
|
+
self,
|
|
1213
|
+
reader: asyncio.StreamReader,
|
|
1214
|
+
channel: Literal["stdout", "stderr"],
|
|
1215
|
+
) -> None:
|
|
1216
|
+
orphan = self._orphan_stdout if channel == "stdout" else self._orphan_stderr
|
|
1217
|
+
while chunk := await reader.read(_READ_CHUNK):
|
|
1218
|
+
capture = self._capture
|
|
1219
|
+
if capture is None:
|
|
1220
|
+
orphan.feed(chunk)
|
|
1221
|
+
else:
|
|
1222
|
+
capture.feed(channel, chunk)
|
|
1223
|
+
|
|
1224
|
+
async def _drain_diagnostic(self) -> None:
|
|
1225
|
+
while chunk := await self._diagnostic_reader.read(_READ_CHUNK):
|
|
1226
|
+
self._diagnostic.feed(chunk)
|
|
1227
|
+
|
|
1228
|
+
@staticmethod
|
|
1229
|
+
async def _settle_stream_readers() -> None:
|
|
1230
|
+
await _settle_ready_stream_readers()
|
|
1231
|
+
|
|
1232
|
+
def _close_channels(self) -> None:
|
|
1233
|
+
if self._channels_closed:
|
|
1234
|
+
return
|
|
1235
|
+
self._channels_closed = True
|
|
1236
|
+
try:
|
|
1237
|
+
os.close(self._request_fd)
|
|
1238
|
+
except OSError:
|
|
1239
|
+
pass
|
|
1240
|
+
self._response_transport.close()
|
|
1241
|
+
self._diagnostic_transport.close()
|
|
1242
|
+
for task in self._reader_tasks:
|
|
1243
|
+
task.cancel()
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _create_protocol_pipes() -> tuple[int, int, int, int, int, int]:
|
|
1247
|
+
descriptors: list[int] = []
|
|
1248
|
+
try:
|
|
1249
|
+
for _ in range(3):
|
|
1250
|
+
read_descriptor, write_descriptor = os.pipe()
|
|
1251
|
+
descriptors.extend((read_descriptor, write_descriptor))
|
|
1252
|
+
except BaseException:
|
|
1253
|
+
for descriptor in descriptors:
|
|
1254
|
+
_close_fd(descriptor)
|
|
1255
|
+
raise
|
|
1256
|
+
return (
|
|
1257
|
+
descriptors[0],
|
|
1258
|
+
descriptors[1],
|
|
1259
|
+
descriptors[2],
|
|
1260
|
+
descriptors[3],
|
|
1261
|
+
descriptors[4],
|
|
1262
|
+
descriptors[5],
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
async def _cleanup_spawned_process(
|
|
1267
|
+
process: asyncio.subprocess.Process,
|
|
1268
|
+
operations: _ProcessOperations,
|
|
1269
|
+
) -> str | None:
|
|
1270
|
+
errors: list[str] = []
|
|
1271
|
+
try:
|
|
1272
|
+
operations.signal_group(process.pid, signal.SIGKILL)
|
|
1273
|
+
except ProcessLookupError:
|
|
1274
|
+
pass
|
|
1275
|
+
except Exception as error:
|
|
1276
|
+
errors.append(f"SIGKILL failed: {type(error).__name__}: {error}")
|
|
1277
|
+
try:
|
|
1278
|
+
async with asyncio.timeout(KILL_GRACE):
|
|
1279
|
+
await process.wait()
|
|
1280
|
+
except TimeoutError:
|
|
1281
|
+
errors.append("Runner did not exit after SIGKILL")
|
|
1282
|
+
try:
|
|
1283
|
+
if operations.group_exists(process.pid):
|
|
1284
|
+
errors.append("managed process group remains after SIGKILL")
|
|
1285
|
+
except Exception as error:
|
|
1286
|
+
errors.append(f"group verification failed: {type(error).__name__}: {error}")
|
|
1287
|
+
return "; ".join(errors) or None
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _spawned_process_cleanup(
|
|
1291
|
+
process: asyncio.subprocess.Process,
|
|
1292
|
+
operations: _ProcessOperations,
|
|
1293
|
+
) -> Callable[[], Awaitable[ShutdownResult]]:
|
|
1294
|
+
async def cleanup() -> ShutdownResult:
|
|
1295
|
+
error = await _cleanup_spawned_process(process, operations)
|
|
1296
|
+
return ShutdownResult(error is None, error)
|
|
1297
|
+
|
|
1298
|
+
return cleanup
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
async def _run_cleanup_operation(
|
|
1302
|
+
cleanup: Callable[[], Awaitable[ShutdownResult]],
|
|
1303
|
+
) -> ShutdownResult:
|
|
1304
|
+
try:
|
|
1305
|
+
return await cleanup()
|
|
1306
|
+
except Exception as error:
|
|
1307
|
+
return ShutdownResult(False, f"startup cleanup failed: {type(error).__name__}: {error}")
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _public_infrastructure_message(error_type: str) -> str:
|
|
1311
|
+
messages = {
|
|
1312
|
+
"CleanupError": "managed kernel cleanup failed",
|
|
1313
|
+
"ArtifactStoreError": "execution output storage failed",
|
|
1314
|
+
"KernelExited": "kernel process exited unexpectedly",
|
|
1315
|
+
"RunnerProtocolError": "kernel protocol failed",
|
|
1316
|
+
"RunnerProtocolTimeout": "kernel protocol timed out",
|
|
1317
|
+
"RuntimeDiagnosticsError": "kernel runtime diagnostics failed",
|
|
1318
|
+
}
|
|
1319
|
+
return messages.get(error_type, "kernel infrastructure failed")
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _close_fd(descriptor: int) -> None:
|
|
1323
|
+
try:
|
|
1324
|
+
os.close(descriptor)
|
|
1325
|
+
except OSError:
|
|
1326
|
+
pass
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _protocol_error_type(response: ProtocolErrorResponse) -> str:
|
|
1330
|
+
if response.code == "runtime_diagnostics_error":
|
|
1331
|
+
return "RuntimeDiagnosticsError"
|
|
1332
|
+
return "RunnerProtocolError"
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
async def _settle_ready_stream_readers() -> None:
|
|
1336
|
+
# A zero-delay timer runs after already-ready pipe callbacks. Their
|
|
1337
|
+
# awakened reader tasks then run before this task resumes.
|
|
1338
|
+
loop = asyncio.get_running_loop()
|
|
1339
|
+
settled = loop.create_future()
|
|
1340
|
+
handle = loop.call_later(0, settled.set_result, None)
|
|
1341
|
+
try:
|
|
1342
|
+
await settled
|
|
1343
|
+
finally:
|
|
1344
|
+
handle.cancel()
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
async def _deadline_after(delay: float) -> float:
|
|
1348
|
+
await asyncio.sleep(delay)
|
|
1349
|
+
return time.monotonic()
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _select_intervention(
|
|
1353
|
+
*,
|
|
1354
|
+
deadline: float | None,
|
|
1355
|
+
output_limit: float | None,
|
|
1356
|
+
cancellation: float | None,
|
|
1357
|
+
output_failure: float | None,
|
|
1358
|
+
deadline_and_limit_same_turn: bool,
|
|
1359
|
+
) -> tuple[float, Literal["deadline", "output limit", "cancellation", "output failure"]]:
|
|
1360
|
+
if output_failure is not None:
|
|
1361
|
+
return output_failure, "output failure"
|
|
1362
|
+
simultaneous_deadline_limit = (
|
|
1363
|
+
deadline is not None and output_limit is not None and deadline_and_limit_same_turn
|
|
1364
|
+
)
|
|
1365
|
+
candidates = [
|
|
1366
|
+
(when, priority, reason)
|
|
1367
|
+
for when, priority, reason in (
|
|
1368
|
+
(deadline, 0, "deadline"),
|
|
1369
|
+
(None if simultaneous_deadline_limit else output_limit, 1, "output limit"),
|
|
1370
|
+
(cancellation, 2, "cancellation"),
|
|
1371
|
+
)
|
|
1372
|
+
if when is not None
|
|
1373
|
+
]
|
|
1374
|
+
when, _, reason = min(candidates)
|
|
1375
|
+
return when, cast(
|
|
1376
|
+
Literal["deadline", "output limit", "cancellation", "output failure"],
|
|
1377
|
+
reason,
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _compose_execution(core: _CoreOutcome, output: OutputResult) -> SupervisedExecution:
|
|
1382
|
+
return SupervisedExecution(
|
|
1383
|
+
status=core.status,
|
|
1384
|
+
stdout=output.stdout,
|
|
1385
|
+
stderr=output.stderr,
|
|
1386
|
+
has_result=core.has_result,
|
|
1387
|
+
result=core.result,
|
|
1388
|
+
error=core.error,
|
|
1389
|
+
execution_count=core.execution_count,
|
|
1390
|
+
duration_ms=core.duration_ms,
|
|
1391
|
+
kernel_state=core.kernel_state,
|
|
1392
|
+
runtime=core.runtime,
|
|
1393
|
+
output=output,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
async def _stream_reader_from_fd(fd: int) -> tuple[asyncio.StreamReader, asyncio.ReadTransport]:
|
|
1398
|
+
loop = asyncio.get_running_loop()
|
|
1399
|
+
stream = os.fdopen(fd, "rb", buffering=0)
|
|
1400
|
+
reader = asyncio.StreamReader(limit=MAX_CONTROL_FRAME_BYTES + 4)
|
|
1401
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
1402
|
+
transport, _ = await loop.connect_read_pipe(lambda: protocol, stream)
|
|
1403
|
+
return reader, transport
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def configured_runtime(working_directory: Path | None = None) -> RuntimeDiagnostics:
|
|
1407
|
+
"""Return host-known Runner diagnostics before or after process loss."""
|
|
1408
|
+
launch_directory = (working_directory or Path.cwd()).resolve(strict=True)
|
|
1409
|
+
return RuntimeDiagnostics(
|
|
1410
|
+
executable=os.path.abspath(sys.executable),
|
|
1411
|
+
python_version=platform.python_version(),
|
|
1412
|
+
working_directory=str(launch_directory),
|
|
1413
|
+
environment_kind=_classify_environment(launch_directory),
|
|
1414
|
+
implementation=platform.python_implementation(),
|
|
1415
|
+
platform=platform.platform(),
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _runner_environment() -> dict[str, str]:
|
|
1420
|
+
environment = {key: value for key, value in os.environ.items() if key in _ENVIRONMENT_ALLOWLIST}
|
|
1421
|
+
if sys.prefix != sys.base_prefix:
|
|
1422
|
+
environment["VIRTUAL_ENV"] = os.path.abspath(sys.prefix)
|
|
1423
|
+
return environment
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def _classify_environment(
|
|
1427
|
+
launch_working_directory: Path,
|
|
1428
|
+
) -> Literal["project_venv", "active_venv", "system"]:
|
|
1429
|
+
if sys.prefix == sys.base_prefix:
|
|
1430
|
+
return "system"
|
|
1431
|
+
project_environment = launch_working_directory / ".venv"
|
|
1432
|
+
try:
|
|
1433
|
+
if project_environment.exists() and os.path.samefile(sys.prefix, project_environment):
|
|
1434
|
+
return "project_venv"
|
|
1435
|
+
except OSError:
|
|
1436
|
+
pass
|
|
1437
|
+
return "active_venv"
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _duration_ms(started: float) -> int:
|
|
1441
|
+
return max(0, int((time.monotonic() - started) * 1_000))
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
async def _cancel_tasks(*tasks: asyncio.Task[object]) -> None:
|
|
1445
|
+
for task in tasks:
|
|
1446
|
+
if not task.done():
|
|
1447
|
+
task.cancel()
|
|
1448
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _resolve_future(future: asyncio.Future[None]) -> None:
|
|
1452
|
+
if not future.done():
|
|
1453
|
+
future.set_result(None)
|