- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
"""Spawn a process and stream its stdout/stderr as an async generator.
|
|
|
|
Mirrors ``strands-ts/src/sandbox/stream-process.ts``.
|
|
|
|
The shared process-supervision engine behind the shell-based backends (Docker,
|
|
SSH): a backend builds an argv, and this spawns the process, streams its output
|
|
as :class:`StreamChunk` objects, and yields a final :class:`ExecutionResult`.
|
|
|
|
Cancellation is cooperative -- cancelling the consuming task (or closing the
|
|
generator) runs the cleanup in ``finally``, which kills the process. ``timeout``
|
|
is wall-clock: measured from spawn and not reset by ongoing output.
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
import signal
|
|
from collections.abc import AsyncGenerator
|
|
|
|
from .errors import SandboxTimeoutError
|
|
from .types import ExecutionResult, StreamChunk, StreamType
|
|
|
|
_READ_CHUNK_SIZE = 65536
|
|
_SIGNAL_EXIT_BASE = 128
|
|
|
|
# The child is spawned in its own process group (start_new_session) so the whole
|
|
# tree can be killed at once. Without this, "sh -c 'cmd; sleep 60'" leaves the
|
|
# sleep child holding the pipe write-end open after the parent dies, so the
|
|
# readers never see EOF and the generator hangs.
|
|
_USE_PROCESS_GROUP = hasattr(os, "killpg")
|
|
|
|
|
|
def _kill_tree(proc: asyncio.subprocess.Process) -> None:
|
|
"""SIGKILL the entire process tree (the group on POSIX, the lone process elsewhere)."""
|
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
if _USE_PROCESS_GROUP:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
else:
|
|
proc.kill()
|
|
|
|
|
|
async def _stream_process(
|
|
program: str,
|
|
args: list[str],
|
|
*,
|
|
timeout: float | None = None,
|
|
enoent_message: str | None = None,
|
|
) -> AsyncGenerator[StreamChunk | ExecutionResult, None]:
|
|
"""Spawn a command and stream its stdout/stderr, yielding the final result.
|
|
|
|
Yields :class:`StreamChunk` objects as output arrives, then a single final
|
|
:class:`ExecutionResult`. Signal termination maps to ``128 + signal`` (e.g.
|
|
SIGKILL -> 137). Cancelling the consuming task kills the process.
|
|
|
|
Args:
|
|
program: The binary to spawn (e.g. ``"docker"``, ``"ssh"``).
|
|
args: Arguments to pass to the binary.
|
|
timeout: Maximum wall-clock execution time in seconds, measured from
|
|
spawn (not reset by output). ``None`` means no timeout.
|
|
enoent_message: Message to surface as ``stderr`` (with exit code 127)
|
|
when ``program`` is not on PATH. If ``None``, the underlying
|
|
:class:`FileNotFoundError` propagates instead.
|
|
|
|
Yields:
|
|
:class:`StreamChunk` objects for output, then a final
|
|
:class:`ExecutionResult`.
|
|
|
|
Raises:
|
|
SandboxTimeoutError: If execution exceeds ``timeout`` seconds.
|
|
FileNotFoundError: If ``program`` is not found and ``enoent_message`` is ``None``.
|
|
"""
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
program,
|
|
*args,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
start_new_session=_USE_PROCESS_GROUP,
|
|
)
|
|
except FileNotFoundError:
|
|
if enoent_message is None:
|
|
raise
|
|
yield ExecutionResult(exit_code=127, stdout="", stderr=enoent_message)
|
|
return
|
|
|
|
queue: asyncio.Queue[StreamChunk | None] = asyncio.Queue()
|
|
out_buf: list[str] = []
|
|
err_buf: list[str] = []
|
|
timed_out = False
|
|
|
|
async def pump(stream: asyncio.StreamReader, stream_type: StreamType, buf: list[str]) -> None:
|
|
while data := await stream.read(_READ_CHUNK_SIZE):
|
|
text = data.decode(errors="replace")
|
|
buf.append(text)
|
|
await queue.put(StreamChunk(data=text, stream_type=stream_type))
|
|
|
|
assert proc.stdout is not None and proc.stderr is not None
|
|
pumps = asyncio.gather(pump(proc.stdout, "stdout", out_buf), pump(proc.stderr, "stderr", err_buf))
|
|
|
|
async def reader() -> None:
|
|
await pumps
|
|
await proc.wait()
|
|
await queue.put(None)
|
|
|
|
async def enforce_timeout(timeout: float) -> None:
|
|
nonlocal timed_out
|
|
await asyncio.sleep(timeout)
|
|
if proc.returncode is None:
|
|
timed_out = True
|
|
_kill_tree(proc)
|
|
await queue.put(None) # signal the consumer loop to stop
|
|
|
|
tasks = [asyncio.ensure_future(reader())]
|
|
if timeout is not None:
|
|
tasks.append(asyncio.ensure_future(enforce_timeout(timeout)))
|
|
|
|
try:
|
|
while (item := await queue.get()) is not None:
|
|
yield item
|
|
|
|
if timed_out:
|
|
raise SandboxTimeoutError(timeout, "".join(out_buf), "".join(err_buf))
|
|
|
|
returncode = proc.returncode if proc.returncode is not None else 1
|
|
exit_code = _SIGNAL_EXIT_BASE - returncode if returncode < 0 else returncode
|
|
yield ExecutionResult(exit_code=exit_code, stdout="".join(out_buf), stderr="".join(err_buf))
|
|
finally:
|
|
if proc.returncode is None:
|
|
_kill_tree(proc)
|
|
pumps.cancel()
|
|
for task in tasks:
|
|
task.cancel()
|
|
await asyncio.gather(pumps, *tasks, return_exceptions=True)
|