104 lines
3.4 KiB
Python
104 lines
3.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Lightweight upstream command probing.
|
||
|
||
Distinguishes the three failure modes that look identical to shutil.which():
|
||
- missing: command not on PATH
|
||
- broken: command exists but cannot execute — most commonly a stale venv
|
||
shebang after a system Python upgrade (pipx/uv tool installs break this
|
||
way: which() finds the shim, but exec fails with FileNotFoundError
|
||
pointing at the shim itself)
|
||
- timeout/error: command runs but misbehaves
|
||
|
||
Channels use probe_command() inside check() so doctor reports real health,
|
||
not just file existence.
|
||
"""
|
||
|
||
import shutil
|
||
import subprocess
|
||
from dataclasses import dataclass
|
||
from typing import Optional, Sequence
|
||
|
||
from agent_reach.utils.process import utf8_subprocess_env
|
||
|
||
#: Exit codes shells use for "found but not executable" / "not found".
|
||
_BROKEN_EXIT_CODES = (126, 127)
|
||
|
||
|
||
@dataclass
|
||
class ProbeResult:
|
||
status: str # "ok" | "missing" | "broken" | "timeout" | "error"
|
||
output: str = ""
|
||
hint: str = ""
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
return self.status == "ok"
|
||
|
||
|
||
def reinstall_hint(package: str) -> str:
|
||
"""Prescription for a broken (stale-venv) CLI install."""
|
||
return (
|
||
f"命令存在但无法执行——通常是系统 Python 升级后 venv 解释器丢失。重装即可修复:\n"
|
||
f" uv tool install --force {package}\n"
|
||
f"或:pipx reinstall {package}"
|
||
)
|
||
|
||
|
||
def probe_command(
|
||
cmd: str,
|
||
args: Sequence[str] = ("--version",),
|
||
timeout: int = 10,
|
||
retries: int = 0,
|
||
package: Optional[str] = None,
|
||
) -> ProbeResult:
|
||
"""Actually execute `cmd *args` and classify the result.
|
||
|
||
Intended for SIDE-EFFECT-FREE health probes only (version/status
|
||
commands): retries re-run the command verbatim with no backoff, so a
|
||
non-idempotent command would repeat its effect.
|
||
|
||
package: pip/pipx package name used in the broken-install hint
|
||
(defaults to cmd).
|
||
"""
|
||
path = shutil.which(cmd)
|
||
if not path:
|
||
return ProbeResult("missing")
|
||
|
||
last: Optional[ProbeResult] = None
|
||
for _ in range(retries + 1):
|
||
last = _run_once(path, args, timeout, package or cmd)
|
||
if last.ok:
|
||
return last
|
||
# missing/broken won't heal between retries — only transient
|
||
# failures (timeout/error) are worth a second attempt
|
||
if last.status in ("missing", "broken"):
|
||
return last
|
||
return last
|
||
|
||
|
||
def _run_once(path: str, args: Sequence[str], timeout: int, package: str) -> ProbeResult:
|
||
try:
|
||
r = subprocess.run(
|
||
[path, *args],
|
||
capture_output=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
timeout=timeout,
|
||
env=utf8_subprocess_env(),
|
||
)
|
||
except FileNotFoundError:
|
||
# which() found it but exec failed: the shebang interpreter is gone
|
||
return ProbeResult("broken", hint=reinstall_hint(package))
|
||
except OSError:
|
||
return ProbeResult("broken", hint=reinstall_hint(package))
|
||
except subprocess.TimeoutExpired:
|
||
return ProbeResult("timeout", hint=f"`{path}` 响应超时(>{timeout}s)")
|
||
|
||
if r.returncode in _BROKEN_EXIT_CODES:
|
||
return ProbeResult("broken", hint=reinstall_hint(package))
|
||
|
||
output = (r.stdout or "") + (r.stderr or "")
|
||
if r.returncode != 0:
|
||
return ProbeResult("error", output=output.strip())
|
||
return ProbeResult("ok", output=output.strip())
|