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
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""Background process watchdog loop (REPL and CLI)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from typing import Protocol, runtime_checkable
|
|
|
|
from platform.common.task_types import TaskRecord, TaskStatus
|
|
from tools.system.fleet_monitoring.probe import probe
|
|
|
|
|
|
@runtime_checkable
|
|
class AlarmDispatcherPort(Protocol):
|
|
"""Minimal alarm-dispatch contract the watchdog depends on.
|
|
|
|
The concrete implementation lives in
|
|
:class:`integrations.telegram.alarms.AlarmDispatcher`, but the watchdog
|
|
stays behind this local protocol so the module never imports
|
|
``integrations.*`` directly (T-4 layering audit, issue #3352, item 37).
|
|
Callers wire the concrete dispatcher in at the CLI entry point.
|
|
"""
|
|
|
|
def dispatch(self, threshold_name: str, message: str) -> bool:
|
|
"""Dispatch one threshold alarm. Returns ``True`` when the alert fired."""
|
|
|
|
|
|
def run_watchdog(
|
|
*,
|
|
task: TaskRecord,
|
|
watched_pid: int,
|
|
interval_seconds: float,
|
|
max_cpu: float | None,
|
|
max_runtime_seconds: float | None,
|
|
max_rss_mib: float | None,
|
|
once: bool,
|
|
dispatcher: AlarmDispatcherPort,
|
|
on_alarm: Callable[[str, str], None] | None,
|
|
) -> None:
|
|
"""Poll ``watched_pid`` until cancel, process exit, or optional single alarm.
|
|
|
|
Updates ``task`` progress each tick; dispatches Telegram on threshold breach.
|
|
``interval_seconds`` is both the inter-sample delay and the CPU percent window
|
|
passed to :func:`~tools.system.fleet_monitoring.probe.probe` (floored for stability).
|
|
With ``once`` and no threshold flags, completes after the first successful sample.
|
|
"""
|
|
sample_interval = max(float(interval_seconds), 0.1)
|
|
|
|
def _notify_alarm(threshold: str, detail: str) -> None:
|
|
if on_alarm is not None:
|
|
on_alarm(threshold, detail)
|
|
|
|
try:
|
|
while True:
|
|
if task.cancel_requested.is_set():
|
|
task.mark_cancelled()
|
|
return
|
|
|
|
snap = probe(watched_pid, cpu_interval=sample_interval)
|
|
if snap is None:
|
|
if task.cancel_requested.is_set():
|
|
task.mark_cancelled()
|
|
elif task.status == TaskStatus.RUNNING:
|
|
task.mark_completed(result="target process exited")
|
|
return
|
|
|
|
runtime_s = max(0.0, (datetime.now(UTC) - snap.started_at).total_seconds())
|
|
progress = (
|
|
f"cpu={snap.cpu_percent:.1f}% rss={snap.rss_mb:.1f}MiB runtime={runtime_s:.0f}s"
|
|
)
|
|
task.update_progress(progress)
|
|
|
|
fired_once = False
|
|
if max_cpu is not None and snap.cpu_percent >= max_cpu:
|
|
detail = f"{snap.cpu_percent:.1f}% (threshold {max_cpu:g}%)"
|
|
msg = (
|
|
f"OpenSRE watchdog: PID {watched_pid} exceeded max_cpu — {detail} "
|
|
f"(task {task.task_id})"
|
|
)
|
|
if dispatcher.dispatch("max_cpu", msg):
|
|
_notify_alarm("max_cpu", detail)
|
|
fired_once = True
|
|
if max_rss_mib is not None and snap.rss_mb >= max_rss_mib:
|
|
detail = f"{snap.rss_mb:.1f}MiB (threshold {max_rss_mib:g}MiB)"
|
|
msg = (
|
|
f"OpenSRE watchdog: PID {watched_pid} exceeded max_rss — {detail} "
|
|
f"(task {task.task_id})"
|
|
)
|
|
if dispatcher.dispatch("max_rss", msg):
|
|
_notify_alarm("max_rss", detail)
|
|
fired_once = True
|
|
if max_runtime_seconds is not None and runtime_s >= max_runtime_seconds:
|
|
detail = f"runtime {runtime_s:.0f}s (threshold {max_runtime_seconds:g}s)"
|
|
msg = (
|
|
f"OpenSRE watchdog: PID {watched_pid} exceeded max_runtime — {detail} "
|
|
f"(task {task.task_id})"
|
|
)
|
|
if dispatcher.dispatch("max_runtime", msg):
|
|
_notify_alarm("max_runtime", detail)
|
|
fired_once = True
|
|
|
|
any_threshold = (
|
|
max_cpu is not None or max_rss_mib is not None or max_runtime_seconds is not None
|
|
)
|
|
if once:
|
|
if fired_once:
|
|
task.mark_completed(result="alarm (once)")
|
|
return
|
|
if not any_threshold:
|
|
task.mark_completed(result="single sample (once)")
|
|
return
|
|
|
|
if task.cancel_requested.is_set():
|
|
task.mark_cancelled()
|
|
return
|
|
except Exception as exc: # noqa: BLE001
|
|
if task.status == TaskStatus.RUNNING:
|
|
task.mark_failed(str(exc))
|
|
|
|
|
|
def start_watchdog_daemon_thread(
|
|
*,
|
|
task: TaskRecord,
|
|
watched_pid: int,
|
|
interval_seconds: float,
|
|
max_cpu: float | None,
|
|
max_runtime_seconds: float | None,
|
|
max_rss_mib: float | None,
|
|
once: bool,
|
|
dispatcher: AlarmDispatcherPort,
|
|
on_alarm: Callable[[str, str], None] | None,
|
|
) -> threading.Thread:
|
|
"""Start :func:`run_watchdog` on a daemon thread named ``watchdog-<task_id>``."""
|
|
|
|
thread = threading.Thread(
|
|
target=run_watchdog,
|
|
kwargs={
|
|
"task": task,
|
|
"watched_pid": watched_pid,
|
|
"interval_seconds": interval_seconds,
|
|
"max_cpu": max_cpu,
|
|
"max_runtime_seconds": max_runtime_seconds,
|
|
"max_rss_mib": max_rss_mib,
|
|
"once": once,
|
|
"dispatcher": dispatcher,
|
|
"on_alarm": on_alarm,
|
|
},
|
|
daemon=True,
|
|
name=f"watchdog-{task.task_id}",
|
|
)
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
__all__ = ["AlarmDispatcherPort", "run_watchdog", "start_watchdog_daemon_thread"]
|