simbiot-harness/venv/lib/python3.12/site-packages/strands/sandbox/stream_process.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- 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 первым)
2026-09-29 15:28:50 +00:00

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)