3 Commits

Author SHA1 Message Date
6c75ade463 feat(rpa): ensure_profile_free for same-profile browser release (1.2.13)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
Add precise Chrome/Edge quit by user-data-dir and stale SingletonLock cleanup so publish RPAs can free account-manager windows before launch without killing the default browser.
2026-10-21 09:07:27 +08:00
28abd3509a fix(logging): use space-separated timestamps in unified log (1.2.12)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 16s
Replace ISO T with YYYY-MM-DD HH:MM:SS so runtime log UI can wrap and display cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 14:09:25 +08:00
c9f5c37af8 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>
2026-08-12 10:36:32 +08:00
14 changed files with 1107 additions and 7 deletions

View File

@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
## 3. 包含的模块 ## 3. 包含的模块
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。 - **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)、**RPA 有界就绪等待**(`rpa.poll_until`)、可选浏览器启动超时与只读机器供电/限频探测(`machine_power`,不阻断 health)等,供 Skill CLI / 服务进程使用。
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。 - **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
- **jiangchang_desktop_wake**:Windows 桌面 UIA 暖树。技能/拾取请用 `ensure_ui_ready("wecom")`(内部 `n2-wake`:注入 + DuiLib GetAccessible)。随包携带 x86 `native/desktop-wake.exe` 与 `wecom_wake.dll`。详见 `src/jiangchang_desktop_wake/README.md`。探测深遍历需另装 `uiautomation`。 - **jiangchang_desktop_wake**:Windows 桌面 UIA 暖树。技能/拾取请用 `ensure_ui_ready("wecom")`(内部 `n2-wake`:注入 + DuiLib GetAccessible)。随包携带 x86 `native/desktop-wake.exe` 与 `wecom_wake.dll`。详见 `src/jiangchang_desktop_wake/README.md`。探测深遍历需另装 `uiautomation`。
@@ -278,6 +278,48 @@ print(runtime_diagnostics_dict(diag)) # 可 JSON 序列化
诊断只读探测本地状态,不会触发 media-assets 下载。 诊断只读探测本地状态,不会触发 media-assets 下载。
自 **1.2.12** 起,统一文件日志 `jiangchang.log` 的时间戳为 `YYYY-MM-DD HH:MM:SS`(空格分隔,不再使用 ISO 的 `T`)。宿主运行日志解析需同时兼容新旧两种格式。
自 **1.2.11** 起,`runtime_diagnostics_dict` 额外包含只读字段 `machine_power`(AC 供电 / 可选 nvidia-smi 限频信息)。探不到则 `probed=false`,**不会**因此把 health 标为 fatal。
自 **1.2.13** 起,提供按 Profile 精准关闭:`ensure_profile_free` / `force_quit_profile_browsers`(只杀命令行含该 `user-data-dir` 的 Chrome/Edge,不碰系统默认浏览器),并可选清理崩溃残留 `SingletonLock`。
### RPA 有界就绪等待与启动超时(1.2.11+)
```python
from jiangchang_skill_core.rpa import poll_until
from jiangchang_skill_core.rpa.errors import ERR_UI_NOT_READY, ERR_BROWSER_START_TIMEOUT
# 有界轮询:超时返回 timed_out,不抛异常;等待可响应宿主 pause/stop
result = await poll_until(lambda: editor_visible, timeout_sec=30, interval_sec=0.5)
if result.timed_out:
raise RuntimeError(f"{ERR_UI_NOT_READY} 页面未在时限内就绪")
# 可选浏览器启动超时(默认关闭,与 1.2.10 行为一致)
# await launch_persistent_browser(pw, profile_dir=..., timeout_ms=60_000)
# 或环境变量 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000
```
### RPA Profile 占用释放(1.2.13+)
```python
from jiangchang_skill_core.rpa import ensure_profile_free, launch_persistent_browser
from jiangchang_skill_core.rpa.errors import ERR_PROFILE_IN_USE
# 发布前:关掉账号管理「打开浏览器」占用的同一 Profile,再启动
result = ensure_profile_free(profile_dir)
# result.pids_before / killed_ops / stale_lock_cleared / freed
# 或在统一启动封装上打开开关(默认 False,保持旧行为)
# context = await launch_persistent_browser(pw, profile_dir=..., ensure_free=True)
```
薄错误码(技能自行决定是否 fatal / retryable):
- `ERROR:UI_NOT_READY`
- `ERROR:BROWSER_START_TIMEOUT`
- `ERROR:ENV_DEGRADED`
### 录屏合成(`screencast`) ### 录屏合成(`screencast`)
`run_screencast()` / `compose_video()` 通过 `jiangchang_skill_core.media_assets` 解析 ffmpeg 与背景音乐,**不使用系统 PATH 中的 `ffmpeg`**。 `run_screencast()` / `compose_video()` 通过 `jiangchang_skill_core.media_assets` 解析 ffmpeg 与背景音乐,**不使用系统 PATH 中的 `ffmpeg`**。

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jiangchang-platform-kit" name = "jiangchang-platform-kit"
version = "1.2.10" version = "1.2.13"
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

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 __future__ import annotations
from . import anti_detect, artifacts, errors, human_login, stealth from . import anti_detect, artifacts, errors, human_login, stealth, wait
try: try:
from .browser import launch_persistent_browser from .browser import launch_persistent_browser
@@ -14,14 +14,36 @@ try:
except ImportError: except ImportError:
RpaVideoSession = None # type: ignore[misc, assignment] RpaVideoSession = None # type: ignore[misc, assignment]
from .profile_lock import (
EnsureProfileFreeResult,
clear_stale_singleton_locks,
ensure_profile_free,
force_quit_profile_browsers,
is_profile_lock_error,
list_profile_browser_pids,
list_profile_browser_root_pids,
)
from .wait import PollUntilResult, poll_until, poll_until_sync
__all__ = [ __all__ = [
"anti_detect", "anti_detect",
"artifacts", "artifacts",
"errors", "errors",
"human_login", "human_login",
"stealth", "stealth",
"wait",
"launch_persistent_browser", "launch_persistent_browser",
"RpaVideoSession", "RpaVideoSession",
"PollUntilResult",
"poll_until",
"poll_until_sync",
"EnsureProfileFreeResult",
"clear_stale_singleton_locks",
"ensure_profile_free",
"force_quit_profile_browsers",
"is_profile_lock_error",
"list_profile_browser_pids",
"list_profile_browser_root_pids",
# re-export common anti_detect helpers # re-export common anti_detect helpers
"random_delay", "random_delay",
"human_delay_short", "human_delay_short",

View File

@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .. import config from .. import config
from ..runtime_env import find_chrome_executable from ..runtime_env import find_chrome_executable
from .errors import ERR_BROWSER_START_TIMEOUT, ERR_MISSING_BROWSER
from .stealth import ( from .stealth import (
STEALTH_INIT_SCRIPT, STEALTH_INIT_SCRIPT,
persistent_context_launch_parts, persistent_context_launch_parts,
@@ -15,6 +17,23 @@ from .stealth import (
if TYPE_CHECKING: if TYPE_CHECKING:
from playwright.async_api import BrowserContext 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( async def launch_persistent_browser(
playwright, playwright,
@@ -24,19 +43,36 @@ async def launch_persistent_browser(
executable_path: str | None = None, executable_path: str | None = None,
headless: bool | None = None, headless: bool | None = None,
extra_args: list[str] | None = None, extra_args: list[str] | None = None,
timeout_ms: int | None = None,
ensure_free: bool = False,
) -> "BrowserContext": ) -> "BrowserContext":
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。 """用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。 headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。 录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
timeout_ms:
可选启动超时(毫秒)。为 None 时读 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS;
仍为空则不包裹超时(与历史行为一致)。超时抛 RuntimeError,前缀
ERROR:BROWSER_START_TIMEOUT。本函数不做自动重试。
ensure_free:
为 True 时,启动前调用 ``ensure_profile_free``:精准关闭占用该
Profile 的 Chrome/Edge,并清理崩溃残留 Singleton 锁。默认 False,
保持历史行为;发布类技能建议传 True,或自行先调 ensure_profile_free。
""" """
if headless is None: if headless is None:
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
if ensure_free:
from .profile_lock import ensure_profile_free
ensure_profile_free(profile_dir)
chrome = executable_path or find_chrome_executable() chrome = executable_path or find_chrome_executable()
if not chrome: if not chrome:
raise RuntimeError( raise RuntimeError(
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。" f"{ERR_MISSING_BROWSER} 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
) )
args, ignore = persistent_context_launch_parts(extra_args=extra_args) args, ignore = persistent_context_launch_parts(extra_args=extra_args)
@@ -51,7 +87,22 @@ async def launch_persistent_browser(
if ignore is not None: if ignore is not None:
launch_kwargs["ignore_default_args"] = ignore launch_kwargs["ignore_default_args"] = ignore
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) 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(): if stealth_enabled():
await context.add_init_script(STEALTH_INIT_SCRIPT) await context.add_init_script(STEALTH_INIT_SCRIPT)
return context return context

View File

@@ -8,6 +8,12 @@ ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY" ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND" 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"
ERR_PROFILE_IN_USE = "ERROR:PROFILE_IN_USE"
__all__ = [ __all__ = [
"ERR_REQUIRE_LOGIN", "ERR_REQUIRE_LOGIN",
"ERR_LOGIN_TIMEOUT", "ERR_LOGIN_TIMEOUT",
@@ -16,4 +22,8 @@ __all__ = [
"ERR_MISSING_BROWSER", "ERR_MISSING_BROWSER",
"ERR_DEVICE_NOT_READY", "ERR_DEVICE_NOT_READY",
"ERR_WINDOW_NOT_FOUND", "ERR_WINDOW_NOT_FOUND",
"ERR_UI_NOT_READY",
"ERR_BROWSER_START_TIMEOUT",
"ERR_ENV_DEGRADED",
"ERR_PROFILE_IN_USE",
] ]

View File

@@ -0,0 +1,384 @@
"""按 Chromium Profile(user-data-dir)精准占用检测 / 关闭 / 僵锁清理。
只匹配命令行包含该 Profile 绝对路径的 chrome/msedge,不碰系统默认浏览器。
供发布类 RPA 在 launch_persistent_context 前调用,避免「账号管理打开浏览器未关」撞锁失败。
"""
from __future__ import annotations
import base64
import logging
import os
import subprocess
import sys
import time
from dataclasses import dataclass, field
from typing import Iterable, List, Sequence
from .errors import ERR_PROFILE_IN_USE
_logger = logging.getLogger(__name__)
# 过短路径误匹配面过大(正式账号 Profile 远长于此)
_MIN_PROFILE_PATH_LEN = 12
_LOCK_HINTS = (
"processsingleton",
"profile is already in use",
"user data directory is already in use",
"singletonlock",
"browser is already running",
"failed to create a processsingleton",
)
_SINGLETON_NAMES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
@dataclass(frozen=True)
class EnsureProfileFreeResult:
"""ensure_profile_free 的结构化结果(便于技能打日志)。"""
profile_dir: str
pids_before: tuple[int, ...] = ()
killed_ops: int = 0
stale_lock_cleared: bool = False
pids_after: tuple[int, ...] = ()
freed: bool = True
notes: tuple[str, ...] = field(default_factory=tuple)
def is_profile_lock_error(exc: BaseException) -> bool:
"""判断异常文案是否像 Chromium Profile 被占用。"""
msg = str(exc).lower()
return any(n in msg for n in _LOCK_HINTS)
def _norm_profile(profile_dir: str) -> str:
return os.path.abspath((profile_dir or "").strip())
def _profile_path_b64(profile_dir: str) -> str:
"""UTF-8 Base64,供 PowerShell 解码;避免 -Command 引号/编码弄坏中文路径。"""
return base64.b64encode(_norm_profile(profile_dir).encode("utf-8")).decode("ascii")
def _ps_decode_profile_path(path_b64: str) -> str:
return (
"$ErrorActionPreference = 'SilentlyContinue'\n"
f"$p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{path_b64}'))\n"
"if (-not $p) { exit 0 }\n"
)
def _dedupe_pids(pids: Iterable[int]) -> list[int]:
seen: set[int] = set()
out: list[int] = []
for pid in pids:
if pid <= 0 or pid in seen:
continue
seen.add(pid)
out.append(pid)
return out
def list_profile_browser_pids(profile_dir: str) -> list[int]:
"""查找占用该 user-data-dir 的 chrome/msedge 进程(匹配完整 Profile 路径)。"""
profile_abs = _norm_profile(profile_dir)
if not profile_abs or len(profile_abs) < _MIN_PROFILE_PATH_LEN:
return []
if sys.platform == "win32":
return _list_pids_win(profile_abs, roots_only=False)
return _list_pids_posix(profile_abs, roots_only=False)
def list_profile_browser_root_pids(profile_dir: str) -> list[int]:
"""仅主进程(命令行不含 --type=),便于 taskkill /T 收整棵进程树。"""
profile_abs = _norm_profile(profile_dir)
if not profile_abs or len(profile_abs) < _MIN_PROFILE_PATH_LEN:
return []
if sys.platform == "win32":
return _list_pids_win(profile_abs, roots_only=True)
return _list_pids_posix(profile_abs, roots_only=True)
def _list_pids_win(profile_abs: str, *, roots_only: bool) -> list[int]:
root_clause = (
"$_.CommandLine -notmatch '--type='"
if roots_only
else "$true"
)
ps = (
_ps_decode_profile_path(_profile_path_b64(profile_abs))
+ "Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='msedge.exe'\" |\n"
+ " Where-Object {\n"
+ " $_.CommandLine -and\n"
+ " $_.CommandLine.Contains($p) -and\n"
+ f" ({root_clause})\n"
+ " } |\n"
+ " ForEach-Object { $_.ProcessId }\n"
)
try:
out = subprocess.check_output(
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps],
text=True,
encoding="utf-8",
errors="replace",
timeout=20,
)
return _dedupe_pids(
int(line.strip()) for line in (out or "").splitlines() if line.strip().isdigit()
)
except Exception:
_logger.warning(
"profile_pid_scan_failed profile_dir=%s roots_only=%s",
profile_abs,
roots_only,
exc_info=True,
)
return []
def _list_pids_posix(profile_abs: str, *, roots_only: bool) -> list[int]:
"""尽力而为:扫 chrome/chromium/msedge 命令行。无 psutil 依赖。"""
try:
out = subprocess.check_output(
["ps", "-ax", "-o", "pid=,command="],
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
)
except Exception:
_logger.warning("profile_pid_scan_failed_posix profile_dir=%s", profile_abs, exc_info=True)
return []
needle = profile_abs
names = ("chrome", "chromium", "msedge", "google-chrome")
pids: list[int] = []
for line in (out or "").splitlines():
text = line.strip()
if not text:
continue
parts = text.split(None, 1)
if len(parts) < 2:
continue
try:
pid = int(parts[0])
except ValueError:
continue
cmd = parts[1]
if needle not in cmd:
continue
low = cmd.lower()
if not any(n in low for n in names):
continue
if roots_only and "--type=" in cmd:
continue
pids.append(pid)
return _dedupe_pids(pids)
def force_quit_profile_browsers(profile_dir: str, *, settle_sec: float = 0.4) -> int:
"""仅结束命令行包含该 Profile 绝对路径的 Chrome/Edge 进程树。
返回发起结束的次数(含二次清残留),不表示最终仍占用的进程数。
"""
raw = (profile_dir or "").strip()
if not raw:
_logger.warning("browser_force_quit_skipped_bad_profile profile_dir=%r", profile_dir)
return 0
profile_abs = _norm_profile(raw)
if len(profile_abs) < _MIN_PROFILE_PATH_LEN:
_logger.warning("browser_force_quit_skipped_bad_profile profile_dir=%r", profile_dir)
return 0
killed = 0
roots = list_profile_browser_root_pids(profile_abs)
targets = roots or list_profile_browser_pids(profile_abs)
killed += _kill_pids(targets)
if settle_sec > 0:
time.sleep(settle_sec)
leftovers = list_profile_browser_pids(profile_abs)
if leftovers:
killed += _kill_pids(leftovers, force=True)
remain = list_profile_browser_pids(profile_abs)
_logger.info(
"browser_force_quit profile_dir=%s roots=%s killed_ops=%s remain=%s",
profile_abs,
roots,
killed,
remain,
)
return killed
def _kill_pids(pids: Sequence[int], *, force: bool = False) -> int:
ops = 0
for pid in pids:
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
else:
os.kill(pid, 9 if force else 15)
ops += 1
except Exception:
_logger.warning("browser_force_quit_pid_failed pid=%s", pid, exc_info=True)
return ops
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
if sys.platform == "win32":
# 0 信号在 Windows 上不可靠;用 tasklist
out = subprocess.check_output(
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
)
return str(pid) in (out or "")
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except Exception:
return False
def _parse_singleton_target_pid(lock_path: str) -> int | None:
"""从 SingletonLock 解析目标 PID(symlink 目标形如 hostname-pid)。"""
try:
if os.path.islink(lock_path):
target = os.readlink(lock_path)
else:
# Windows 上偶发为普通文件,内容也可能是 hostname-pid
with open(lock_path, "rb") as fh:
raw = fh.read(256)
target = raw.decode("utf-8", errors="ignore").strip()
if not target:
return None
pid_s = target.rsplit("-", 1)[-1].strip()
pid = int(pid_s)
return pid if pid > 0 else None
except (OSError, ValueError):
return None
def clear_stale_singleton_locks(profile_dir: str) -> bool:
"""若 Profile 无存活占用进程,清理崩溃残留的 Singleton* 文件。
有活进程占用时不删。锁指向的 PID 仍存活时也不删。
返回是否删除了至少一个文件。
"""
profile_abs = _norm_profile(profile_dir)
if not profile_abs or not os.path.isdir(profile_abs):
return False
if list_profile_browser_pids(profile_abs):
return False
lock_path = os.path.join(profile_abs, "SingletonLock")
if os.path.lexists(lock_path):
pid = _parse_singleton_target_pid(lock_path)
if pid is not None and _pid_alive(pid):
# 锁指向的进程仍在,但命令行未必含本路径(少见)——保守不删
_logger.info(
"singleton_lock_held_by_pid profile_dir=%s pid=%s",
profile_abs,
pid,
)
return False
removed = False
for name in _SINGLETON_NAMES:
path = os.path.join(profile_abs, name)
if not os.path.lexists(path):
continue
try:
os.remove(path)
removed = True
_logger.info("stale_singleton_removed profile_dir=%s name=%s", profile_abs, name)
except OSError:
_logger.warning(
"stale_singleton_remove_failed profile_dir=%s name=%s",
profile_abs,
name,
exc_info=True,
)
return removed
def ensure_profile_free(
profile_dir: str,
*,
force_quit: bool = True,
clear_stale_lock: bool = True,
wait_sec: float = 1.0,
raise_if_busy: bool = True,
) -> EnsureProfileFreeResult:
"""确保指定 Profile 可被 Playwright launch_persistent_context 独占使用。
默认:精准关闭占用该目录的浏览器 → 清僵锁 → 复查。
仍被占用且 raise_if_busy=True 时抛 RuntimeError(前缀 ERROR:PROFILE_IN_USE)。
"""
profile_abs = _norm_profile(profile_dir)
notes: List[str] = []
if not profile_abs:
raise ValueError("profile_dir 不能为空")
if len(profile_abs) < _MIN_PROFILE_PATH_LEN:
raise ValueError(f"profile_dir 过短,拒绝操作: {profile_abs!r}")
before = tuple(list_profile_browser_pids(profile_abs))
killed_ops = 0
if before and force_quit:
killed_ops = force_quit_profile_browsers(profile_abs)
notes.append(f"force_quit killed_ops={killed_ops}")
elif before and not force_quit:
notes.append("occupied_skip_quit")
stale_cleared = False
if clear_stale_lock:
stale_cleared = clear_stale_singleton_locks(profile_abs)
if stale_cleared:
notes.append("stale_singleton_cleared")
if wait_sec > 0:
time.sleep(wait_sec)
after = tuple(list_profile_browser_pids(profile_abs))
freed = len(after) == 0
result = EnsureProfileFreeResult(
profile_dir=profile_abs,
pids_before=before,
killed_ops=killed_ops,
stale_lock_cleared=stale_cleared,
pids_after=after,
freed=freed,
notes=tuple(notes),
)
_logger.info(
"ensure_profile_free profile_dir=%s before=%s after=%s killed_ops=%s "
"stale_cleared=%s freed=%s",
profile_abs,
before,
after,
killed_ops,
stale_cleared,
freed,
)
if not freed and raise_if_busy:
raise RuntimeError(
f"{ERR_PROFILE_IN_USE} 账号浏览器配置目录仍被占用(pids={list(after)}):"
f"{profile_abs}。请关闭账号管理打开的对应窗口后重试。"
)
return result

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 import jiangchang_skill_core
from . import config 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 .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
from .runtime_env import platform_default_data_root from .runtime_env import platform_default_data_root
@@ -51,6 +52,8 @@ class RuntimeDiagnostics:
record_video_enabled: bool record_video_enabled: bool
issues: tuple[RuntimeIssue, ...] issues: tuple[RuntimeIssue, ...]
# 只读机器信号(供电/限频);探不到为空 dict / probed=False,永不单独导致 fatal
machine_power: dict[str, Any]
@property @property
def has_fatal_issues(self) -> bool: 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( return RuntimeDiagnostics(
skill_slug=skill_slug, skill_slug=skill_slug,
python_executable=sys.executable, python_executable=sys.executable,
@@ -276,6 +285,7 @@ def collect_runtime_diagnostics(
background_music_sample_path=str(music_sample) if music_sample else None, background_music_sample_path=str(music_sample) if music_sample else None,
record_video_enabled=record_video_enabled, record_video_enabled=record_video_enabled,
issues=tuple(issues), 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_issue: {diagnostics.background_music_issue or ''}",
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}", f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
f"record_video_enabled: {diagnostics.record_video_enabled}", 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: for issue in diagnostics.issues:
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}") lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
return lines return lines
@@ -321,6 +348,7 @@ def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
"background_music_issue": diagnostics.background_music_issue, "background_music_issue": diagnostics.background_music_issue,
"background_music_sample_path": diagnostics.background_music_sample_path, "background_music_sample_path": diagnostics.background_music_sample_path,
"record_video_enabled": diagnostics.record_video_enabled, "record_video_enabled": diagnostics.record_video_enabled,
"machine_power": diagnostics.machine_power,
"runtime_issues": [ "runtime_issues": [
{"code": issue.code, "message": issue.message, "severity": issue.severity} {"code": issue.code, "message": issue.message, "severity": issue.severity}
for issue in diagnostics.issues for issue in diagnostics.issues

View File

@@ -87,7 +87,7 @@ class _SkillContextFilter(logging.Filter):
_FORMAT = ( _FORMAT = (
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s" "%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
) )
_DATEFMT = "%Y-%m-%dT%H:%M:%S" _DATEFMT = "%Y-%m-%d %H:%M:%S"
def _log_level_from_env() -> int: def _log_level_from_env() -> int:

134
tests/test_profile_lock.py Normal file
View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
"""Profile 精准关闭 / 僵锁清理单元测试。"""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from jiangchang_skill_core.rpa.errors import ERR_PROFILE_IN_USE
from jiangchang_skill_core.rpa.profile_lock import (
clear_stale_singleton_locks,
ensure_profile_free,
force_quit_profile_browsers,
is_profile_lock_error,
)
class TestProfileLockHelpers(unittest.TestCase):
def test_is_profile_lock_error(self) -> None:
self.assertTrue(
is_profile_lock_error(
RuntimeError("User data directory is already in use")
)
)
self.assertTrue(is_profile_lock_error(RuntimeError("SingletonLock")))
self.assertFalse(is_profile_lock_error(RuntimeError("timeout waiting")))
def test_force_quit_rejects_short_path(self) -> None:
self.assertEqual(force_quit_profile_browsers("C:\\a"), 0)
def test_clear_stale_singleton_when_no_pids(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) / "account-manager" / "profiles" / "acc-1"
profile.mkdir(parents=True)
lock = profile / "SingletonLock"
cookie = profile / "SingletonCookie"
lock.write_text("deadhost-999999", encoding="utf-8")
cookie.write_text("x", encoding="utf-8")
with mock.patch(
"jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids",
return_value=[],
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock._pid_alive",
return_value=False,
):
removed = clear_stale_singleton_locks(str(profile))
self.assertTrue(removed)
self.assertFalse(lock.exists())
self.assertFalse(cookie.exists())
def test_clear_stale_skips_when_pids_present(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) / "profiles" / "acc-2"
profile.mkdir(parents=True)
lock = profile / "SingletonLock"
lock.write_text("host-1", encoding="utf-8")
with mock.patch(
"jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids",
return_value=[1234],
):
removed = clear_stale_singleton_locks(str(profile))
self.assertFalse(removed)
self.assertTrue(lock.exists())
def test_ensure_profile_free_quits_then_ok(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) / "profiles" / "acc-long-enough"
profile.mkdir(parents=True)
with mock.patch(
"jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids",
side_effect=[[111], []],
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.force_quit_profile_browsers",
return_value=1,
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.clear_stale_singleton_locks",
return_value=False,
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.time.sleep",
):
result = ensure_profile_free(
str(profile), wait_sec=0, raise_if_busy=True
)
self.assertTrue(result.freed)
self.assertEqual(result.pids_before, (111,))
self.assertEqual(result.pids_after, ())
self.assertEqual(result.killed_ops, 1)
def test_ensure_profile_free_raises_when_still_busy(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) / "profiles" / "acc-still-busy"
profile.mkdir(parents=True)
with mock.patch(
"jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids",
return_value=[222],
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.force_quit_profile_browsers",
return_value=1,
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.clear_stale_singleton_locks",
return_value=False,
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.time.sleep",
):
with self.assertRaises(RuntimeError) as ctx:
ensure_profile_free(str(profile), wait_sec=0, raise_if_busy=True)
self.assertIn(ERR_PROFILE_IN_USE, str(ctx.exception))
def test_ensure_profile_free_no_raise_when_requested(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) / "profiles" / "acc-keep-busy"
profile.mkdir(parents=True)
with mock.patch(
"jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids",
return_value=[333],
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.force_quit_profile_browsers",
return_value=0,
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.clear_stale_singleton_locks",
return_value=False,
), mock.patch(
"jiangchang_skill_core.rpa.profile_lock.time.sleep",
):
result = ensure_profile_free(
str(profile), wait_sec=0, raise_if_busy=False
)
self.assertFalse(result.freed)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
"""poll_until / 薄错误码 / browser launch timeout / machine_power 探测。"""
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from jiangchang_skill_core.machine_power import probe_machine_power
from jiangchang_skill_core.rpa import errors
from jiangchang_skill_core.rpa.browser import launch_persistent_browser
from jiangchang_skill_core.rpa.wait import poll_until, poll_until_sync
def test_poll_until_succeeds_when_predicate_becomes_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sleeps: list[float] = []
async def _fake_sleep(seconds: float, *, chunk=None):
del chunk
sleeps.append(seconds)
monkeypatch.setattr(
"jiangchang_skill_core.rpa.wait.interruptible_sleep",
_fake_sleep,
)
state = {"n": 0}
def ready() -> bool:
state["n"] += 1
return state["n"] >= 3
result = asyncio.run(poll_until(ready, timeout_sec=2.0, interval_sec=0.1))
assert result.ok is True
assert result.timed_out is False
assert state["n"] == 3
assert sleeps
def test_poll_until_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_sleep(seconds: float, *, chunk=None):
del chunk, seconds
monkeypatch.setattr(
"jiangchang_skill_core.rpa.wait.interruptible_sleep",
_fake_sleep,
)
result = asyncio.run(poll_until(lambda: False, timeout_sec=0.01, interval_sec=0.01))
assert result.ok is False
assert result.timed_out is True
def test_poll_until_sync_ok(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"jiangchang_skill_core.rpa.wait.interruptible_sleep_sync",
lambda seconds, *, chunk=None: None,
)
assert poll_until_sync(lambda: True, timeout_sec=1.0).ok is True
def test_thin_error_codes_exist() -> None:
assert errors.ERR_UI_NOT_READY == "ERROR:UI_NOT_READY"
assert errors.ERR_BROWSER_START_TIMEOUT == "ERROR:BROWSER_START_TIMEOUT"
assert errors.ERR_ENV_DEGRADED == "ERROR:ENV_DEGRADED"
assert errors.ERR_MISSING_BROWSER == "ERROR:MISSING_BROWSER"
def test_launch_persistent_browser_timeout_maps_code(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"jiangchang_skill_core.rpa.browser.find_chrome_executable",
lambda: r"C:\fake\chrome.exe",
)
monkeypatch.setattr(
"jiangchang_skill_core.rpa.browser.persistent_context_launch_parts",
lambda extra_args=None: ([], None),
)
monkeypatch.setattr(
"jiangchang_skill_core.rpa.browser.stealth_enabled",
lambda: False,
)
async def _hang(**kwargs):
del kwargs
await asyncio.sleep(10)
raise AssertionError("should have been cancelled by wait_for")
playwright = MagicMock()
playwright.chromium.launch_persistent_context = _hang
async def _run():
with pytest.raises(RuntimeError) as ctx:
await launch_persistent_browser(
playwright,
profile_dir=r"C:\fake\profile",
timeout_ms=50,
)
assert str(ctx.value).startswith(errors.ERR_BROWSER_START_TIMEOUT)
asyncio.run(_run())
def test_launch_persistent_browser_missing_browser(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"jiangchang_skill_core.rpa.browser.find_chrome_executable",
lambda: None,
)
async def _run():
with pytest.raises(RuntimeError) as ctx:
await launch_persistent_browser(MagicMock(), profile_dir=r"C:\fake\profile")
assert str(ctx.value).startswith(errors.ERR_MISSING_BROWSER)
asyncio.run(_run())
def test_probe_machine_power_shape_and_no_raise() -> None:
payload = probe_machine_power()
assert "probed" in payload
assert "ac_power" in payload
assert "nvidia" in payload
assert isinstance(payload["probed"], bool)
def test_collect_runtime_diagnostics_includes_machine_power_without_fatal(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from jiangchang_skill_core import media_assets as ma
from jiangchang_skill_core.runtime_diagnostics import (
collect_runtime_diagnostics,
runtime_diagnostics_dict,
)
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"), "OPENCLAW_RECORD_VIDEO": "0"}
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
lambda *a, **k: ma.MediaAssetsStatus(
root=tmp_path,
exists=True,
ready=True,
source="local",
warnings=[],
ffmpeg_path=tmp_path / "ffmpeg.exe",
music_dir=tmp_path / "music",
music_ready=True,
),
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
lambda *a, **k: {
"music_root": str(tmp_path / "music"),
"mp3_count": 1,
"usable_count": 1,
"sample_path": str(tmp_path / "music" / "a.mp3"),
"issue": None,
},
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
lambda: "1.2.11",
)
monkeypatch.setattr(
"jiangchang_skill_core.runtime_diagnostics.probe_machine_power",
lambda: {
"probed": True,
"ac_power": {
"ac_line_status": 1,
"on_ac_power": True,
"battery_life_percent": 100,
},
"nvidia": {
"gpu_name": "Fake",
"enforced_power_limit_w": 140.0,
"power_limit_w": 140.0,
"clocks_throttle_reasons_active": "None",
},
},
)
diag = collect_runtime_diagnostics(skill_slug="demo", env=env)
payload = runtime_diagnostics_dict(diag)
assert payload["machine_power"]["probed"] is True
assert diag.has_fatal_issues is False

View File

@@ -357,6 +357,7 @@ def test_runtime_diagnostics_frozen_dataclass() -> None:
background_music_sample_path=None, background_music_sample_path=None,
record_video_enabled=False, record_video_enabled=False,
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),), issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
machine_power={"probed": False, "ac_power": None, "nvidia": None},
) )
assert diag.has_fatal_issues is False assert diag.has_fatal_issues is False
assert diag.issue_codes() == ["ffmpeg_unavailable"] assert diag.issue_codes() == ["ffmpeg_unavailable"]
@@ -381,6 +382,7 @@ def test_format_runtime_health_lines_issue_separator_ascii() -> None:
background_music_sample_path=None, background_music_sample_path=None,
record_video_enabled=False, record_video_enabled=False,
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),), issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
machine_power={"probed": False, "ac_power": None, "nvidia": None},
) )
lines = format_runtime_health_lines(diag) lines = format_runtime_health_lines(diag)
issue_lines = [line for line in lines if line.startswith("runtime_issue[")] issue_lines = [line for line in lines if line.startswith("runtime_issue[")]

View File

@@ -45,6 +45,25 @@ class TestUnifiedLogging(unittest.TestCase):
self.assertFalse(any(isinstance(h, TimedRotatingFileHandler) for h in handlers)) self.assertFalse(any(isinstance(h, TimedRotatingFileHandler) for h in handlers))
self.assertTrue(any(isinstance(h, logging.NullHandler) for h in handlers)) self.assertTrue(any(isinstance(h, logging.NullHandler) for h in handlers))
def test_log_timestamp_uses_space_not_iso_t(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
log_path = os.path.join(tmp, "skill.log")
logger_name = "test.logger.datefmt"
with patch.object(ul, "get_skill_log_file_path", return_value=log_path):
ul.setup_skill_logging("receive-order", logger_name)
ul.get_skill_logger().info("hello-datefmt")
ul._clear_logger_handlers(logging.getLogger(logger_name))
with open(log_path, encoding="utf-8") as f:
text = f.read()
self.assertRegex(
text,
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|",
)
self.assertNotRegex(
text,
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} \|",
)
def test_attach_unified_file_handler_unwritable(self) -> None: def test_attach_unified_file_handler_unwritable(self) -> None:
logger_name = "test.logger.attach.unwritable" logger_name = "test.logger.attach.unwritable"
with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")): with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")):