4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
228 lines
6.9 KiB
Python
228 lines
6.9 KiB
Python
"""Pure subprocess primitives and presenter port for interactive-shell action tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Protocol, runtime_checkable
|
|
|
|
from core.agent_harness.tools.tool_context import ActionToolContext
|
|
from tools.interactive_shell.shared import ExecutionPolicyResult
|
|
|
|
# --- constants ---
|
|
|
|
SHELL_COMMAND_TIMEOUT_SECONDS = 120
|
|
SYNTHETIC_TEST_TIMEOUT_SECONDS = 1800
|
|
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS = 1800
|
|
SYNTHETIC_POLL_SECONDS = 0.25
|
|
MAX_COMMAND_OUTPUT_CHARS = 24_000
|
|
SYNTHETIC_DIAG_CHARS = 2_000
|
|
SIGTERM_GRACE_SECONDS = 10
|
|
TASK_OUTPUT_JOIN_TIMEOUT_SECONDS = 2
|
|
|
|
# Width of the ``<task_id> <stream> │ `` prefix relayed subprocess lines add.
|
|
TASK_OUTPUT_PREFIX_WIDTH = 18
|
|
MIN_SUBPROCESS_TERMINAL_WIDTH = 60
|
|
|
|
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[mA-Za-z]")
|
|
|
|
|
|
# --- lifecycle ---
|
|
|
|
|
|
def terminate_child_process(proc: subprocess.Popen[Any]) -> None:
|
|
"""Best-effort SIGTERM → wait → SIGKILL → wait without blocking forever."""
|
|
if proc.poll() is not None:
|
|
return
|
|
with contextlib.suppress(OSError):
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=SIGTERM_GRACE_SECONDS)
|
|
except subprocess.TimeoutExpired:
|
|
with contextlib.suppress(OSError):
|
|
proc.kill()
|
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
proc.wait(timeout=5)
|
|
|
|
|
|
def read_task_output(
|
|
buf: tempfile.SpooledTemporaryFile[bytes] | None, # type: ignore[type-arg]
|
|
*,
|
|
limit: int,
|
|
) -> str:
|
|
"""Read up to ``limit`` bytes from a captured output buffer, ANSI-stripped."""
|
|
if buf is None:
|
|
return ""
|
|
try:
|
|
buf.seek(0)
|
|
raw = buf.read(limit).decode("utf-8", errors="replace").strip()
|
|
except (OSError, ValueError):
|
|
return ""
|
|
return _ANSI_ESCAPE.sub("", raw)
|
|
|
|
|
|
def read_diag(buf: tempfile.SpooledTemporaryFile[bytes]) -> str: # type: ignore[type-arg]
|
|
"""Read up to ``SYNTHETIC_DIAG_CHARS`` bytes from a captured stderr buffer."""
|
|
return read_task_output(buf, limit=SYNTHETIC_DIAG_CHARS)
|
|
|
|
|
|
# --- environment ---
|
|
|
|
|
|
def subprocess_env_with_width(*, columns: int, lines: int | None = None) -> dict[str, str]:
|
|
"""Return ``os.environ`` patched so a piped Rich subprocess wraps to fit."""
|
|
available = max(
|
|
MIN_SUBPROCESS_TERMINAL_WIDTH,
|
|
columns - TASK_OUTPUT_PREFIX_WIDTH - 1,
|
|
)
|
|
env = dict(os.environ)
|
|
env["COLUMNS"] = str(available)
|
|
env.setdefault("LINES", str(max(20, lines or 24)))
|
|
return env
|
|
|
|
|
|
# --- watcher ---
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubprocessWatchResult:
|
|
"""Outcome of watching a child process until exit, timeout, or cancel."""
|
|
|
|
timed_out: bool
|
|
cancelled: bool
|
|
exit_code: int | None
|
|
terminated_by_watcher: bool
|
|
|
|
|
|
def watch_subprocess_until_exit(
|
|
proc: subprocess.Popen[Any],
|
|
*,
|
|
cancel_event: threading.Event,
|
|
timeout_seconds: float,
|
|
poll_seconds: float = SYNTHETIC_POLL_SECONDS,
|
|
) -> SubprocessWatchResult:
|
|
"""Poll ``proc`` until it exits, ``cancel_event`` is set, or ``timeout_seconds`` elapses."""
|
|
started = time.monotonic()
|
|
timed_out = False
|
|
terminated_by_watcher = False
|
|
while proc.poll() is None:
|
|
if time.monotonic() - started > timeout_seconds:
|
|
timed_out = True
|
|
terminate_child_process(proc)
|
|
terminated_by_watcher = True
|
|
break
|
|
if cancel_event.is_set():
|
|
terminate_child_process(proc)
|
|
terminated_by_watcher = True
|
|
break
|
|
time.sleep(poll_seconds)
|
|
return SubprocessWatchResult(
|
|
timed_out=timed_out,
|
|
cancelled=cancel_event.is_set(),
|
|
exit_code=proc.returncode,
|
|
terminated_by_watcher=terminated_by_watcher,
|
|
)
|
|
|
|
|
|
# --- presenter port ---
|
|
|
|
|
|
@runtime_checkable
|
|
class SubprocessPresenter(Protocol):
|
|
"""Surface-injected UI and session hooks for subprocess runners."""
|
|
|
|
@property
|
|
def session(self) -> Any:
|
|
"""Mutable per-turn session (``core.agent_harness.session.Session``)."""
|
|
|
|
def execution_allowed(
|
|
self,
|
|
policy: ExecutionPolicyResult,
|
|
*,
|
|
action_summary: str,
|
|
) -> bool:
|
|
"""Apply execution policy UX and return whether the action may proceed."""
|
|
|
|
def print(self, message: str = "") -> None:
|
|
"""Print a Rich-markup message."""
|
|
|
|
def print_error(self, message: str) -> None:
|
|
"""Print an error-styled plain-text message."""
|
|
|
|
def print_highlight(self, message: str) -> None:
|
|
"""Print a highlight-styled plain-text message."""
|
|
|
|
def print_bold_command(self, display_command: str) -> None:
|
|
"""Print a ``$ <command>`` header line."""
|
|
|
|
def print_command_output(self, text: str, *, style: str | None = None) -> None:
|
|
"""Print captured subprocess stdout/stderr."""
|
|
|
|
def print_plain(self, text: str) -> None:
|
|
"""Print plain text without Rich markup interpretation."""
|
|
|
|
def report_exception(self, exc: BaseException, *, context: str) -> None:
|
|
"""Report an unexpected exception to observability."""
|
|
|
|
def subprocess_env(self) -> dict[str, str]:
|
|
"""Environment for child subprocesses with terminal width alignment."""
|
|
|
|
def start_task_output_streams(
|
|
self,
|
|
*,
|
|
task: Any,
|
|
proc: subprocess.Popen[Any],
|
|
stdout_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
|
|
stderr_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
|
|
) -> list[threading.Thread]:
|
|
"""Start relay threads for a background task's stdout/stderr."""
|
|
|
|
def join_task_output_streams(self, threads: list[threading.Thread]) -> None:
|
|
"""Wait briefly for relay threads to finish."""
|
|
|
|
def start_background_cli_task(
|
|
self,
|
|
*,
|
|
display_command: str,
|
|
argv_list: list[str],
|
|
timeout_seconds: int,
|
|
kind: Any,
|
|
use_pty: bool = False,
|
|
) -> Any:
|
|
"""Launch a background opensre CLI subprocess with streamed output."""
|
|
|
|
|
|
def require_subprocess_presenter(ctx: ActionToolContext) -> SubprocessPresenter:
|
|
presenter = ctx.subprocess_presenter
|
|
if not isinstance(presenter, SubprocessPresenter):
|
|
raise RuntimeError("subprocess presenter is required for this action tool")
|
|
return presenter
|
|
|
|
|
|
__all__ = [
|
|
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
|
|
"MAX_COMMAND_OUTPUT_CHARS",
|
|
"MIN_SUBPROCESS_TERMINAL_WIDTH",
|
|
"SHELL_COMMAND_TIMEOUT_SECONDS",
|
|
"SIGTERM_GRACE_SECONDS",
|
|
"SYNTHETIC_DIAG_CHARS",
|
|
"SYNTHETIC_POLL_SECONDS",
|
|
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
|
|
"SubprocessPresenter",
|
|
"SubprocessWatchResult",
|
|
"TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
|
|
"TASK_OUTPUT_PREFIX_WIDTH",
|
|
"read_diag",
|
|
"read_task_output",
|
|
"require_subprocess_presenter",
|
|
"subprocess_env_with_width",
|
|
"terminate_child_process",
|
|
"watch_subprocess_until_exit",
|
|
]
|