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/runtime.py ADDED
@@ -0,0 +1,581 @@
1
+ """FIFO orchestration for one persistent Python Runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import deque
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Literal, Protocol
11
+
12
+ from amkernel.artifact_store import (
13
+ ArtifactBundle,
14
+ ArtifactIntegrityError,
15
+ ArtifactResource,
16
+ ArtifactStore,
17
+ ArtifactStoreError,
18
+ )
19
+ from amkernel.output import OutputObserver, OutputResult
20
+ from amkernel.protocol import ErrorInfo, RuntimeDiagnostics
21
+ from amkernel.supervisor import (
22
+ KernelStartError,
23
+ KernelSupervisor,
24
+ KernelUnavailableError,
25
+ ShutdownResult,
26
+ SupervisedExecution,
27
+ configured_runtime,
28
+ )
29
+
30
+ ResultFinalizer = Callable[[SupervisedExecution], Awaitable[None]]
31
+
32
+ RuntimeState = Literal["new", "alive", "dead", "closed"]
33
+ _HOST_DIAGNOSTIC_BYTES = 65_536
34
+
35
+
36
+ class RuntimeClosedError(RuntimeError):
37
+ """The Runtime has stopped accepting execution calls."""
38
+
39
+
40
+ class _Supervisor(Protocol):
41
+ @property
42
+ def state(self) -> str: ...
43
+
44
+ @property
45
+ def execution_count(self) -> int: ...
46
+
47
+ @property
48
+ def runtime(self) -> RuntimeDiagnostics: ...
49
+
50
+ @property
51
+ def diagnostic_tail(self) -> str: ...
52
+
53
+ async def execute(
54
+ self,
55
+ code: str,
56
+ timeout: float,
57
+ packages: tuple[str, ...] = (),
58
+ *,
59
+ output_bundle: ArtifactBundle,
60
+ observer: OutputObserver | None = None,
61
+ ) -> SupervisedExecution: ...
62
+ async def shutdown(self) -> ShutdownResult: ...
63
+
64
+
65
+ SupervisorFactory = Callable[[Path], Awaitable[_Supervisor]]
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class _Command:
70
+ code: str
71
+ timeout: float
72
+ reset: bool
73
+ result: asyncio.Future[SupervisedExecution]
74
+ packages: tuple[str, ...]
75
+ cancelled: asyncio.Event = field(default_factory=asyncio.Event)
76
+ observer: OutputObserver | None = None
77
+ result_finalizer: ResultFinalizer | None = None
78
+
79
+
80
+ async def _start_supervisor(working_directory: Path) -> _Supervisor:
81
+ return await KernelSupervisor.start(working_directory)
82
+
83
+
84
+ class KernelRuntime:
85
+ """Serialize execute/reset calls and own one truthful kernel lifecycle."""
86
+
87
+ def __init__(
88
+ self,
89
+ working_directory: Path | None = None,
90
+ *,
91
+ supervisor_factory: SupervisorFactory = _start_supervisor,
92
+ artifact_store: ArtifactStore | None = None,
93
+ ) -> None:
94
+ initial_runtime = configured_runtime(working_directory)
95
+ self._working_directory = Path(initial_runtime.working_directory)
96
+ self._supervisor_factory = supervisor_factory
97
+ self._artifact_store = artifact_store or ArtifactStore()
98
+ self._state: RuntimeState = "new"
99
+ self._supervisor: _Supervisor | None = None
100
+ self._last_runtime = initial_runtime
101
+ self._last_execution_count = 0
102
+ self._configured_runtime = initial_runtime
103
+ self._commands: deque[_Command] = deque()
104
+ self._wake_worker = asyncio.Event()
105
+ self._worker_task: asyncio.Task[None] | None = None
106
+ self._active_command: _Command | None = None
107
+ self._shutdown_task: asyncio.Task[ShutdownResult] | None = None
108
+ self._shutdown_result: ShutdownResult | None = None
109
+ self._worker_cleanup_result: ShutdownResult | None = None
110
+ self._startup_failure: KernelStartError | None = None
111
+ self._diagnostic_tail = ""
112
+ self._pending_output_bundles: list[ArtifactBundle] = []
113
+ self._last_supervisor_diagnostic = ""
114
+
115
+ @property
116
+ def state(self) -> RuntimeState:
117
+ return self._state
118
+
119
+ @property
120
+ def execution_count(self) -> int:
121
+ return self._last_execution_count
122
+
123
+ @property
124
+ def runtime(self) -> RuntimeDiagnostics:
125
+ return self._last_runtime
126
+
127
+ @property
128
+ def diagnostic_tail(self) -> str:
129
+ return self._diagnostic_tail
130
+
131
+ async def execute(
132
+ self,
133
+ code: str,
134
+ *,
135
+ timeout: float = 30.0,
136
+ reset: bool = False,
137
+ packages: tuple[str, ...] = (),
138
+ observer: OutputObserver | None = None,
139
+ result_finalizer: ResultFinalizer | None = None,
140
+ ) -> SupervisedExecution:
141
+ """Queue one cell; caller cancellation abandons or interrupts only that call."""
142
+ if self._state == "closed":
143
+ raise RuntimeClosedError("Runtime is closed")
144
+
145
+ loop = asyncio.get_running_loop()
146
+ command = _Command(
147
+ code,
148
+ timeout,
149
+ reset,
150
+ loop.create_future(),
151
+ packages=packages,
152
+ observer=observer,
153
+ result_finalizer=result_finalizer,
154
+ )
155
+ self._commands.append(command)
156
+ self._ensure_worker()
157
+ self._wake_worker.set()
158
+ try:
159
+ return await asyncio.shield(command.result)
160
+ except asyncio.CancelledError:
161
+ command.cancelled.set()
162
+ command.result.cancel()
163
+ self._wake_worker.set()
164
+ raise
165
+
166
+ async def begin_artifact_bundle(self) -> ArtifactBundle:
167
+ """Reserve one synthetic-producer bundle behind the Runtime seam."""
168
+ if self._state == "closed":
169
+ raise RuntimeClosedError("Runtime is closed")
170
+ return await self._artifact_store.begin_bundle()
171
+
172
+ async def read_resource(self, uri: str) -> ArtifactResource:
173
+ """Read one retained artifact without exposing Store mechanics."""
174
+ try:
175
+ return await self._artifact_store.read(uri)
176
+ except ArtifactIntegrityError:
177
+ self._record_host_diagnostic("Artifact Store resource integrity failure")
178
+ raise
179
+
180
+ async def shutdown(self) -> ShutdownResult:
181
+ """Stop intake immediately and stabilize all owned process work."""
182
+ if self._shutdown_task is None:
183
+ if self._shutdown_result is not None:
184
+ return self._shutdown_result
185
+ self._state = "closed"
186
+ self._shutdown_task = asyncio.create_task(self._shutdown_impl())
187
+
188
+ operation = self._shutdown_task
189
+ caller_cancelled = False
190
+ while True:
191
+ try:
192
+ result = await asyncio.shield(operation)
193
+ break
194
+ except asyncio.CancelledError:
195
+ caller_cancelled = True
196
+ if result.cleaned:
197
+ self._shutdown_result = result
198
+ elif self._shutdown_task is operation:
199
+ self._shutdown_task = None
200
+ if caller_cancelled:
201
+ raise asyncio.CancelledError
202
+ return result
203
+
204
+ def _ensure_worker(self) -> None:
205
+ if self._worker_task is None:
206
+ self._worker_task = asyncio.create_task(self._run_worker())
207
+
208
+ async def _run_worker(self) -> None:
209
+ while True:
210
+ while not self._commands:
211
+ if self._state == "closed":
212
+ self._worker_cleanup_result = await self._close_supervisor()
213
+ return
214
+ self._wake_worker.clear()
215
+ await self._wake_worker.wait()
216
+
217
+ command = self._commands.popleft()
218
+ if command.cancelled.is_set() or command.result.cancelled():
219
+ continue
220
+ if self._state == "closed":
221
+ self._reject_command(command)
222
+ continue
223
+
224
+ self._active_command = command
225
+ try:
226
+ outcome = await self._process(command)
227
+ except Exception as error:
228
+ if not command.result.done():
229
+ command.result.set_exception(error)
230
+ else:
231
+ if (
232
+ outcome is not None
233
+ and not command.cancelled.is_set()
234
+ and not command.result.done()
235
+ and not self._is_closed()
236
+ ):
237
+ command.result.set_result(outcome)
238
+ finally:
239
+ if self._is_closed():
240
+ self._reject_command(command)
241
+ self._active_command = None
242
+
243
+ if self._is_closed():
244
+ self._reject_queued_commands()
245
+ self._worker_cleanup_result = await self._close_supervisor()
246
+ return
247
+
248
+ async def _process(self, command: _Command) -> SupervisedExecution | None:
249
+ if command.reset:
250
+ if not await self._retry_pending_output_bundles():
251
+ if not self._is_closed():
252
+ self._state = "dead"
253
+ return self._dead_execution(
254
+ "ResetCleanupError",
255
+ "managed output cleanup failed during reset",
256
+ )
257
+ reset_failure = await self._replace_supervisor()
258
+ if reset_failure is not None:
259
+ return reset_failure
260
+ elif self._state == "new":
261
+ start_failure = await self._start_new_supervisor()
262
+ if start_failure is not None:
263
+ return start_failure
264
+ elif self._state == "dead":
265
+ return self._dead_execution(
266
+ "KernelUnavailable",
267
+ "kernel is dead; reset=true is required for recovery",
268
+ )
269
+
270
+ if self._state == "closed" or command.cancelled.is_set():
271
+ return None
272
+
273
+ supervisor = self._supervisor
274
+ if supervisor is None or self._state != "alive":
275
+ return self._dead_execution("KernelUnavailable", "kernel is not available")
276
+
277
+ try:
278
+ output_bundle = await self._artifact_store.begin_bundle()
279
+ except ArtifactStoreError as error:
280
+ self._record_host_diagnostic(str(error))
281
+ cleanup = await supervisor.shutdown()
282
+ self._observe_supervisor(supervisor)
283
+ if not cleanup.cleaned:
284
+ return self._dead_execution("CleanupError", "managed kernel cleanup failed")
285
+ return self._dead_execution("ArtifactStoreError", "execution output storage failed")
286
+ if command.observer is None:
287
+ execution = asyncio.create_task(
288
+ supervisor.execute(
289
+ command.code,
290
+ command.timeout,
291
+ command.packages,
292
+ output_bundle=output_bundle,
293
+ )
294
+ )
295
+ else:
296
+ execution = asyncio.create_task(
297
+ supervisor.execute(
298
+ command.code,
299
+ command.timeout,
300
+ command.packages,
301
+ output_bundle=output_bundle,
302
+ observer=command.observer,
303
+ )
304
+ )
305
+ cancellation = asyncio.create_task(command.cancelled.wait())
306
+ try:
307
+ done, _ = await asyncio.wait(
308
+ {execution, cancellation},
309
+ return_when=asyncio.FIRST_COMPLETED,
310
+ )
311
+ if cancellation in done:
312
+ execution.cancel()
313
+ await asyncio.gather(execution, return_exceptions=True)
314
+ await self._abort_unsettled_bundle(output_bundle)
315
+ self._observe_supervisor(supervisor)
316
+ return None
317
+ outcome = await execution
318
+ self._record_execution(supervisor, outcome)
319
+ await self._abort_unsettled_bundle(output_bundle)
320
+ if command.result_finalizer is not None:
321
+ try:
322
+ await command.result_finalizer(outcome)
323
+ except Exception as error:
324
+ self._record_host_diagnostic(
325
+ f"terminal result construction failed: {type(error).__name__}: {error}"
326
+ )
327
+ try:
328
+ await output_bundle.discard()
329
+ except ArtifactStoreError as discard_error:
330
+ self._record_host_diagnostic(str(discard_error))
331
+ self._pending_output_bundles.append(output_bundle)
332
+ cleanup = await supervisor.shutdown()
333
+ self._observe_supervisor(supervisor)
334
+ error_type = "ArtifactStoreError" if cleanup.cleaned else "CleanupError"
335
+ message = (
336
+ "execution output storage failed"
337
+ if cleanup.cleaned
338
+ else "managed kernel cleanup failed"
339
+ )
340
+ fatal = self._dead_execution(
341
+ error_type,
342
+ message,
343
+ duration_ms=outcome.duration_ms,
344
+ output=self._bounded_failed_output(outcome.output),
345
+ )
346
+ try:
347
+ await command.result_finalizer(fatal)
348
+ except Exception as fatal_error:
349
+ self._record_host_diagnostic(
350
+ "fatal terminal result construction failed: "
351
+ f"{type(fatal_error).__name__}: {fatal_error}"
352
+ )
353
+ return fatal
354
+ except KernelUnavailableError:
355
+ await self._abort_unsettled_bundle(output_bundle)
356
+ if not self._is_closed():
357
+ self._state = "dead"
358
+ self._observe_supervisor(supervisor)
359
+ return self._dead_execution("KernelUnavailable", "kernel is not available")
360
+ except BaseException:
361
+ await self._abort_unsettled_bundle(output_bundle)
362
+ raise
363
+ finally:
364
+ if not cancellation.done():
365
+ cancellation.cancel()
366
+ await asyncio.gather(cancellation, return_exceptions=True)
367
+
368
+ return outcome
369
+
370
+ async def _abort_unsettled_bundle(self, bundle: ArtifactBundle) -> None:
371
+ if bundle.settled:
372
+ return
373
+ try:
374
+ await bundle.abort()
375
+ except (ArtifactStoreError, OSError) as error:
376
+ self._record_host_diagnostic(str(error))
377
+ if bundle not in self._pending_output_bundles:
378
+ self._pending_output_bundles.append(bundle)
379
+
380
+ async def _retry_pending_output_bundles(self) -> bool:
381
+ pending = tuple(self._pending_output_bundles)
382
+ self._pending_output_bundles.clear()
383
+ for bundle in pending:
384
+ try:
385
+ await bundle.discard()
386
+ except (ArtifactStoreError, OSError) as error:
387
+ self._record_host_diagnostic(str(error))
388
+ self._pending_output_bundles.append(bundle)
389
+ return not self._pending_output_bundles
390
+
391
+ async def _replace_supervisor(self) -> SupervisedExecution | None:
392
+
393
+ current = self._supervisor
394
+ if current is not None:
395
+ cleanup = await current.shutdown()
396
+ self._observe_supervisor(current)
397
+ if not cleanup.cleaned:
398
+ if self._state != "closed":
399
+ self._state = "dead"
400
+ self._record_host_diagnostic(cleanup.error or "reset cleanup failed")
401
+ return self._dead_execution(
402
+ "ResetCleanupError",
403
+ "managed kernel cleanup failed during reset",
404
+ )
405
+ elif self._startup_failure is not None:
406
+ cleanup = await self._startup_failure.retry_cleanup()
407
+ if not cleanup.cleaned:
408
+ if not self._is_closed():
409
+ self._state = "dead"
410
+ self._record_host_diagnostic(cleanup.error or "startup cleanup retry failed")
411
+ return self._dead_execution(
412
+ "ResetCleanupError",
413
+ "managed kernel cleanup failed during reset",
414
+ )
415
+ self._startup_failure = None
416
+
417
+ self._supervisor = None
418
+ self._last_execution_count = 0
419
+ if self._state == "closed":
420
+ return None
421
+ self._state = "dead"
422
+ return await self._start_new_supervisor()
423
+
424
+ async def _start_new_supervisor(self) -> SupervisedExecution | None:
425
+ try:
426
+ supervisor = await self._supervisor_factory(self._working_directory)
427
+ except KernelStartError as error:
428
+ self._record_host_diagnostic(str(error))
429
+ if error.cleanup_pending:
430
+ self._startup_failure = error
431
+ if not self._is_closed():
432
+ self._state = "dead"
433
+ return self._dead_execution("KernelStartError", "kernel failed to start")
434
+ except Exception as error:
435
+ self._record_host_diagnostic(f"{type(error).__name__}: {error}")
436
+ if not self._is_closed():
437
+ self._state = "dead"
438
+ return self._dead_execution("KernelStartError", "kernel failed to start")
439
+
440
+ self._supervisor = supervisor
441
+ self._startup_failure = None
442
+ self._last_runtime = supervisor.runtime
443
+ self._last_execution_count = supervisor.execution_count
444
+ self._last_supervisor_diagnostic = ""
445
+ self._capture_supervisor_diagnostic(supervisor)
446
+ if self._is_closed():
447
+ return None
448
+ self._state = "alive"
449
+ return None
450
+
451
+ def _record_execution(
452
+ self,
453
+ supervisor: _Supervisor,
454
+ outcome: SupervisedExecution,
455
+ ) -> None:
456
+ self._last_runtime = outcome.runtime
457
+ self._last_execution_count = outcome.execution_count
458
+ self._capture_supervisor_diagnostic(supervisor)
459
+ if not self._is_closed():
460
+ self._state = "alive" if outcome.kernel_state == "alive" else "dead"
461
+
462
+ def _observe_supervisor(self, supervisor: _Supervisor) -> None:
463
+ self._last_runtime = supervisor.runtime
464
+ self._last_execution_count = supervisor.execution_count
465
+ self._capture_supervisor_diagnostic(supervisor)
466
+ if self._state != "closed":
467
+ self._state = "alive" if supervisor.state == "alive" else "dead"
468
+
469
+ def _dead_execution(
470
+ self,
471
+ error_type: str,
472
+ message: str,
473
+ duration_ms: int = 0,
474
+ *,
475
+ output: OutputResult | None = None,
476
+ ) -> SupervisedExecution:
477
+ return SupervisedExecution(
478
+ status="kernel_killed",
479
+ stdout=output.stdout if output is not None else "",
480
+ stderr=output.stderr if output is not None else "",
481
+ has_result=False,
482
+ result=None,
483
+ error=ErrorInfo(error_type, message, ()),
484
+ execution_count=self._last_execution_count,
485
+ duration_ms=duration_ms,
486
+ kernel_state="dead",
487
+ runtime=self._last_runtime,
488
+ output=output or OutputResult.empty(),
489
+ )
490
+
491
+ @staticmethod
492
+ def _bounded_failed_output(output: OutputResult) -> OutputResult:
493
+ return OutputResult(
494
+ stdout=output.stdout,
495
+ stderr=output.stderr,
496
+ total_bytes=output.total_bytes,
497
+ total_lines=output.total_lines,
498
+ discarded_bytes=output.discarded_bytes,
499
+ discarded_lines=output.discarded_lines,
500
+ truncated=output.truncated,
501
+ limit_bytes=output.limit_bytes,
502
+ limit_exceeded=output.limit_exceeded,
503
+ )
504
+
505
+ async def _shutdown_impl(self) -> ShutdownResult:
506
+ self._reject_queued_commands()
507
+ active = self._active_command
508
+ if active is not None:
509
+ active.cancelled.set()
510
+ self._reject_command(active)
511
+ self._wake_worker.set()
512
+
513
+ direct_result = await self._close_supervisor()
514
+ worker = self._worker_task
515
+ if worker is not None and worker is not asyncio.current_task():
516
+ await worker
517
+ worker_result = self._worker_cleanup_result
518
+ await self._retry_pending_output_bundles()
519
+ try:
520
+ await self._artifact_store.cleanup()
521
+ except ArtifactStoreError as error:
522
+ self._record_host_diagnostic(str(error))
523
+ store_result = ShutdownResult(False, "Artifact Store cleanup failed")
524
+ else:
525
+ self._pending_output_bundles.clear()
526
+ store_result = ShutdownResult(True)
527
+ results = (direct_result, worker_result, store_result)
528
+ if all(result is None or result.cleaned for result in results):
529
+ return ShutdownResult(True)
530
+ errors = [
531
+ result.error
532
+ for result in results
533
+ if result is not None and not result.cleaned and result.error is not None
534
+ ]
535
+ return ShutdownResult(False, "; ".join(dict.fromkeys(errors)) or "cleanup failed")
536
+
537
+ async def _close_supervisor(self) -> ShutdownResult:
538
+ supervisor = self._supervisor
539
+ if supervisor is not None:
540
+ result = await supervisor.shutdown()
541
+ self._observe_supervisor(supervisor)
542
+ return result
543
+ startup_failure = self._startup_failure
544
+ if startup_failure is None:
545
+ return ShutdownResult(True)
546
+ result = await startup_failure.retry_cleanup()
547
+ if result.cleaned:
548
+ self._startup_failure = None
549
+ else:
550
+ self._record_host_diagnostic(result.error or "startup cleanup retry failed")
551
+ return result
552
+
553
+ def _capture_supervisor_diagnostic(self, supervisor: _Supervisor) -> None:
554
+ current = supervisor.diagnostic_tail
555
+ previous = self._last_supervisor_diagnostic
556
+ detail = current[len(previous) :] if current.startswith(previous) else current
557
+ self._last_supervisor_diagnostic = current
558
+ if detail:
559
+ self._record_host_diagnostic(detail)
560
+
561
+ def _record_host_diagnostic(self, message: str) -> None:
562
+ combined = f"{self._diagnostic_tail}{message}\n".encode("utf-8", errors="replace")
563
+ tail = combined[-_HOST_DIAGNOSTIC_BYTES:]
564
+ while tail and tail[0] & 0xC0 == 0x80:
565
+ tail = tail[1:]
566
+ self._diagnostic_tail = tail.decode("utf-8", errors="replace")
567
+
568
+ def _is_closed(self) -> bool:
569
+ # State may change across awaits in another task; keep that race visible to mypy.
570
+ return self._state == "closed"
571
+
572
+ def _reject_queued_commands(self) -> None:
573
+ while self._commands:
574
+ command = self._commands.popleft()
575
+ command.cancelled.set()
576
+ self._reject_command(command)
577
+
578
+ @staticmethod
579
+ def _reject_command(command: _Command) -> None:
580
+ if not command.result.done():
581
+ command.result.set_exception(RuntimeClosedError("Runtime is closed"))