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