feat(rpa): add poll_until, env error codes, optional launch timeout (1.2.11)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 19s

Shared readiness wait and thin ERROR:UI_NOT_READY / BROWSER_START_TIMEOUT / ENV_DEGRADED codes so skills can separate slow machines from config failures. Optional browser launch timeout and read-only power/throttle diagnostics (non-blocking).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 10:36:32 +08:00
parent 065d341320
commit c9f5c37af8
10 changed files with 522 additions and 6 deletions

View File

@@ -0,0 +1,106 @@
"""只读机器供电 / GPU 限频探测(失败静默;不作为 hard gate)。"""
from __future__ import annotations
import shutil
import subprocess
import sys
from typing import Any
def _probe_ac_power_windows() -> dict[str, Any] | None:
"""GetSystemPowerStatus;非 Windows 或失败返回 None。"""
if sys.platform != "win32":
return None
try:
import ctypes
from ctypes import wintypes
class SYSTEM_POWER_STATUS(ctypes.Structure):
_fields_ = [
("ACLineStatus", wintypes.BYTE),
("BatteryFlag", wintypes.BYTE),
("BatteryLifePercent", wintypes.BYTE),
("SystemStatusFlag", wintypes.BYTE),
("BatteryLifeTime", wintypes.DWORD),
("BatteryFullLifeTime", wintypes.DWORD),
]
status = SYSTEM_POWER_STATUS()
ok = ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status))
if not ok:
return None
ac = int(status.ACLineStatus)
pct = int(status.BatteryLifePercent)
return {
"ac_line_status": ac, # 0=offline, 1=online, 255=unknown
"on_ac_power": True if ac == 1 else (False if ac == 0 else None),
"battery_life_percent": None if pct > 100 else pct,
}
except Exception:
return None
def _probe_nvidia_throttle() -> dict[str, Any] | None:
"""nvidia-smi 只读查询;无驱动/失败返回 None。"""
smi = shutil.which("nvidia-smi")
if not smi:
return None
try:
completed = subprocess.run(
[
smi,
"--query-gpu=name,enforced.power.limit,power.limit,clocks_throttle_reasons.active",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=3,
check=False,
)
if completed.returncode != 0:
return None
line = (completed.stdout or "").strip().splitlines()
if not line:
return None
# 多卡时只取第一张,足够做环境提示
parts = [p.strip() for p in line[0].split(",")]
if len(parts) < 4:
return None
def _float_or_none(raw: str) -> float | None:
try:
return float(raw)
except ValueError:
return None
throttle = parts[3]
return {
"gpu_name": parts[0] or None,
"enforced_power_limit_w": _float_or_none(parts[1]),
"power_limit_w": _float_or_none(parts[2]),
"clocks_throttle_reasons_active": throttle if throttle else None,
}
except Exception:
return None
def probe_machine_power() -> dict[str, Any]:
"""汇总只读探测结果。字段缺失表示探不到,不视为错误。"""
payload: dict[str, Any] = {
"probed": False,
"ac_power": None,
"nvidia": None,
}
ac = _probe_ac_power_windows()
nvidia = _probe_nvidia_throttle()
if ac is not None:
payload["ac_power"] = ac
payload["probed"] = True
if nvidia is not None:
payload["nvidia"] = nvidia
payload["probed"] = True
return payload
__all__ = ["probe_machine_power"]

View File

@@ -1,8 +1,8 @@
"""浏览器 RPA 共享库:stealth、拟人操作、启动封装、HITL 等待、失败存证。"""
"""浏览器 RPA 共享库:stealth、拟人操作、启动封装、HITL 等待、失败存证、有界就绪等待。"""
from __future__ import annotations
from . import anti_detect, artifacts, errors, human_login, stealth
from . import anti_detect, artifacts, errors, human_login, stealth, wait
try:
from .browser import launch_persistent_browser
@@ -14,14 +14,20 @@ try:
except ImportError:
RpaVideoSession = None # type: ignore[misc, assignment]
from .wait import PollUntilResult, poll_until, poll_until_sync
__all__ = [
"anti_detect",
"artifacts",
"errors",
"human_login",
"stealth",
"wait",
"launch_persistent_browser",
"RpaVideoSession",
"PollUntilResult",
"poll_until",
"poll_until_sync",
# re-export common anti_detect helpers
"random_delay",
"human_delay_short",

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from .. import config
from ..runtime_env import find_chrome_executable
from .errors import ERR_BROWSER_START_TIMEOUT, ERR_MISSING_BROWSER
from .stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
@@ -15,6 +17,23 @@ from .stealth import (
if TYPE_CHECKING:
from playwright.async_api import BrowserContext
# 默认不设启动超时(保持 1.2.10 行为);可通过参数或环境变量开启。
_ENV_LAUNCH_TIMEOUT_MS = "OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS"
def _resolve_launch_timeout_ms(timeout_ms: int | None) -> int | None:
if timeout_ms is not None:
value = int(timeout_ms)
return value if value > 0 else None
raw = (config.get(_ENV_LAUNCH_TIMEOUT_MS, default="") or "").strip()
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value > 0 else None
async def launch_persistent_browser(
playwright,
@@ -24,11 +43,17 @@ async def launch_persistent_browser(
executable_path: str | None = None,
headless: bool | None = None,
extra_args: list[str] | None = None,
timeout_ms: int | None = None,
) -> "BrowserContext":
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
timeout_ms:
可选启动超时(毫秒)。为 None 时读 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS;
仍为空则不包裹超时(与历史行为一致)。超时抛 RuntimeError,前缀
ERROR:BROWSER_START_TIMEOUT。本函数不做自动重试。
"""
if headless is None:
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
@@ -36,7 +61,7 @@ async def launch_persistent_browser(
chrome = executable_path or find_chrome_executable()
if not chrome:
raise RuntimeError(
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
f"{ERR_MISSING_BROWSER} 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
)
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
@@ -51,7 +76,22 @@ async def launch_persistent_browser(
if ignore is not None:
launch_kwargs["ignore_default_args"] = ignore
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
launch_timeout_ms = _resolve_launch_timeout_ms(timeout_ms)
try:
if launch_timeout_ms is None:
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
else:
context = await asyncio.wait_for(
playwright.chromium.launch_persistent_context(**launch_kwargs),
timeout=launch_timeout_ms / 1000.0,
)
except asyncio.TimeoutError as exc:
raise RuntimeError(
f"{ERR_BROWSER_START_TIMEOUT} 浏览器启动超时"
f"({launch_timeout_ms}ms)。机器限频/供电不足时易触发,可增大"
f" {_ENV_LAUNCH_TIMEOUT_MS} 后重试。"
) from exc
if stealth_enabled():
await context.add_init_script(STEALTH_INIT_SCRIPT)
return context

View File

@@ -8,6 +8,11 @@ ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
# 就绪 / 环境类(与配置门禁、MISSING_BROWSER 区分;是否 fatal/retryable 由技能决定)
ERR_UI_NOT_READY = "ERROR:UI_NOT_READY"
ERR_BROWSER_START_TIMEOUT = "ERROR:BROWSER_START_TIMEOUT"
ERR_ENV_DEGRADED = "ERROR:ENV_DEGRADED"
__all__ = [
"ERR_REQUIRE_LOGIN",
"ERR_LOGIN_TIMEOUT",
@@ -16,4 +21,7 @@ __all__ = [
"ERR_MISSING_BROWSER",
"ERR_DEVICE_NOT_READY",
"ERR_WINDOW_NOT_FOUND",
"ERR_UI_NOT_READY",
"ERR_BROWSER_START_TIMEOUT",
"ERR_ENV_DEGRADED",
]

View File

@@ -0,0 +1,112 @@
"""有界就绪等待:在超时内轮询条件,区分「还在加载」与「时间到仍未就绪」。"""
from __future__ import annotations
import inspect
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Union
from ..activity import interruptible_sleep, interruptible_sleep_sync
Predicate = Callable[[], Union[bool, Awaitable[bool]]]
SyncPredicate = Callable[[], bool]
@dataclass(frozen=True)
class PollUntilResult:
"""poll_until / poll_until_sync 的结果。"""
ok: bool
elapsed_sec: float
timed_out: bool = False
@property
def ready(self) -> bool:
return self.ok
async def _call_predicate(predicate: Predicate) -> bool:
result = predicate()
if inspect.isawaitable(result):
result = await result # type: ignore[misc]
return bool(result)
async def poll_until(
predicate: Predicate,
*,
timeout_sec: float,
interval_sec: float = 0.5,
) -> PollUntilResult:
"""异步有界轮询,直到 ``predicate`` 为真或超时。
- 等待切片走 ``interruptible_sleep``,可响应宿主 pause/stop。
- ``predicate`` 可为同步或 async 可调用对象;异常视为尚未就绪(继续轮询)。
- 超时不抛异常,返回 ``timed_out=True``,由调用方映射错误码。
"""
timeout = max(0.0, float(timeout_sec))
interval = max(0.05, float(interval_sec))
started = time.monotonic()
deadline = started + timeout
while True:
try:
if await _call_predicate(predicate):
return PollUntilResult(
ok=True,
elapsed_sec=round(time.monotonic() - started, 3),
timed_out=False,
)
except Exception:
pass
remaining = deadline - time.monotonic()
if remaining <= 0:
return PollUntilResult(
ok=False,
elapsed_sec=round(time.monotonic() - started, 3),
timed_out=True,
)
await interruptible_sleep(min(interval, remaining))
def poll_until_sync(
predicate: SyncPredicate,
*,
timeout_sec: float,
interval_sec: float = 0.5,
) -> PollUntilResult:
"""同步有界轮询(``interruptible_sleep_sync``)。"""
timeout = max(0.0, float(timeout_sec))
interval = max(0.05, float(interval_sec))
started = time.monotonic()
deadline = started + timeout
while True:
try:
if bool(predicate()):
return PollUntilResult(
ok=True,
elapsed_sec=round(time.monotonic() - started, 3),
timed_out=False,
)
except Exception:
pass
remaining = deadline - time.monotonic()
if remaining <= 0:
return PollUntilResult(
ok=False,
elapsed_sec=round(time.monotonic() - started, 3),
timed_out=True,
)
interruptible_sleep_sync(min(interval, remaining))
__all__ = [
"PollUntilResult",
"poll_until",
"poll_until_sync",
]

View File

@@ -12,6 +12,7 @@ from typing import Any, Mapping
import jiangchang_skill_core
from . import config
from .machine_power import probe_machine_power
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
from .runtime_env import platform_default_data_root
@@ -51,6 +52,8 @@ class RuntimeDiagnostics:
record_video_enabled: bool
issues: tuple[RuntimeIssue, ...]
# 只读机器信号(供电/限频);探不到为空 dict / probed=False,永不单独导致 fatal
machine_power: dict[str, Any]
@property
def has_fatal_issues(self) -> bool:
@@ -258,6 +261,12 @@ def collect_runtime_diagnostics(
)
)
# 附加项:只读供电/限频;异常吞掉,不影响 health fatal
try:
machine_power = probe_machine_power()
except Exception:
machine_power = {"probed": False, "ac_power": None, "nvidia": None}
return RuntimeDiagnostics(
skill_slug=skill_slug,
python_executable=sys.executable,
@@ -276,6 +285,7 @@ def collect_runtime_diagnostics(
background_music_sample_path=str(music_sample) if music_sample else None,
record_video_enabled=record_video_enabled,
issues=tuple(issues),
machine_power=machine_power,
)
@@ -297,7 +307,24 @@ def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
f"background_music_issue: {diagnostics.background_music_issue or ''}",
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
f"record_video_enabled: {diagnostics.record_video_enabled}",
f"machine_power_probed: {bool((diagnostics.machine_power or {}).get('probed'))}",
]
power = diagnostics.machine_power or {}
ac = power.get("ac_power") or {}
if ac:
lines.append(f"machine_ac_line_status: {ac.get('ac_line_status', '')}")
lines.append(f"machine_on_ac_power: {ac.get('on_ac_power', '')}")
lines.append(f"machine_battery_life_percent: {ac.get('battery_life_percent', '')}")
nvidia = power.get("nvidia") or {}
if nvidia:
lines.append(f"machine_gpu_name: {nvidia.get('gpu_name', '')}")
lines.append(
f"machine_enforced_power_limit_w: {nvidia.get('enforced_power_limit_w', '')}"
)
lines.append(
"machine_clocks_throttle_reasons_active: "
f"{nvidia.get('clocks_throttle_reasons_active', '')}"
)
for issue in diagnostics.issues:
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
return lines
@@ -321,6 +348,7 @@ def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
"background_music_issue": diagnostics.background_music_issue,
"background_music_sample_path": diagnostics.background_music_sample_path,
"record_video_enabled": diagnostics.record_video_enabled,
"machine_power": diagnostics.machine_power,
"runtime_issues": [
{"code": issue.code, "message": issue.message, "severity": issue.severity}
for issue in diagnostics.issues