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
134 lines
4.0 KiB
Python
134 lines
4.0 KiB
Python
"""Agent process lifecycle management.
|
|
|
|
Provides ``terminate()`` — a SIGTERM-then-SIGKILL helper used by the
|
|
``/fleet kill`` slash command to stop a runaway local AI agent from
|
|
within the opensre interactive shell.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ``os.kill(pid, 0)`` on Windows raises ``OSError`` (WinError 87) for invalid PIDs
|
|
# instead of ``ProcessLookupError``.
|
|
_WIN_ERR_INVALID_PARAMETER = 87
|
|
|
|
# Default grace period between SIGTERM and SIGKILL escalation.
|
|
DEFAULT_GRACE_SECONDS: float = 5.0
|
|
|
|
# Polling interval while waiting for the process to exit after SIGTERM.
|
|
_POLL_INTERVAL: float = 0.1
|
|
|
|
|
|
def _pid_alive(pid: int) -> bool:
|
|
"""Return True if *pid* still exists (works on Unix and macOS)."""
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
# Process exists but we lack permission — treat as alive.
|
|
return True
|
|
except OSError as exc:
|
|
if sys.platform == "win32" and getattr(exc, "winerror", None) == _WIN_ERR_INVALID_PARAMETER:
|
|
return False
|
|
raise
|
|
|
|
|
|
def _assert_target_pid_exists(pid: int) -> None:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
raise
|
|
except OSError as exc:
|
|
if sys.platform == "win32" and getattr(exc, "winerror", None) == _WIN_ERR_INVALID_PARAMETER:
|
|
raise ProcessLookupError(pid) from exc
|
|
raise
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TerminateResult:
|
|
"""Outcome of a :func:`terminate` call."""
|
|
|
|
pid: int
|
|
exited: bool
|
|
signal_sent: str # "SIGTERM" | "SIGKILL"
|
|
elapsed_seconds: float
|
|
|
|
|
|
def terminate(pid: int, *, grace_s: float = DEFAULT_GRACE_SECONDS) -> TerminateResult:
|
|
"""Send SIGTERM, wait up to *grace_s* seconds, escalate to SIGKILL.
|
|
|
|
Returns a :class:`TerminateResult` describing what happened.
|
|
Raises ``ProcessLookupError`` if *pid* does not exist at call time.
|
|
Raises ``PermissionError`` if the calling user cannot signal *pid*.
|
|
"""
|
|
# Validate that the process exists before proceeding.
|
|
_assert_target_pid_exists(pid)
|
|
|
|
t0 = time.monotonic()
|
|
|
|
# --- SIGTERM ---
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
# Raced: process exited between the check and the signal.
|
|
return TerminateResult(
|
|
pid=pid,
|
|
exited=True,
|
|
signal_sent="SIGTERM",
|
|
elapsed_seconds=time.monotonic() - t0,
|
|
)
|
|
|
|
logger.info("Sent SIGTERM to pid %d, waiting up to %.1fs", pid, grace_s)
|
|
|
|
deadline = t0 + grace_s
|
|
while time.monotonic() < deadline:
|
|
if not _pid_alive(pid):
|
|
return TerminateResult(
|
|
pid=pid,
|
|
exited=True,
|
|
signal_sent="SIGTERM",
|
|
elapsed_seconds=time.monotonic() - t0,
|
|
)
|
|
time.sleep(_POLL_INTERVAL)
|
|
|
|
# --- SIGKILL escalation (or second SIGTERM on platforms without SIGKILL, e.g. Windows)
|
|
logger.warning("pid %d did not exit after SIGTERM; sending SIGKILL", pid)
|
|
kill_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
|
|
try:
|
|
os.kill(pid, kill_signal)
|
|
except ProcessLookupError:
|
|
return TerminateResult(
|
|
pid=pid,
|
|
exited=True,
|
|
signal_sent="SIGTERM",
|
|
elapsed_seconds=time.monotonic() - t0,
|
|
)
|
|
|
|
# Brief wait for SIGKILL to take effect.
|
|
kill_deadline = time.monotonic() + 1.0
|
|
while time.monotonic() < kill_deadline:
|
|
if not _pid_alive(pid):
|
|
return TerminateResult(
|
|
pid=pid,
|
|
exited=True,
|
|
signal_sent="SIGKILL",
|
|
elapsed_seconds=time.monotonic() - t0,
|
|
)
|
|
time.sleep(_POLL_INTERVAL)
|
|
return TerminateResult(
|
|
pid=pid,
|
|
exited=not _pid_alive(pid),
|
|
signal_sent="SIGKILL",
|
|
elapsed_seconds=time.monotonic() - t0,
|
|
)
|