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/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)