Files
prose-contracts/scripts/agent-health-check.py
root 17a77e6b3f
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 2s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 1s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 1s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
fix: fleet config issues from 2026-07-18 relay review
- hermes-agent-baseline: add gpu-dense and gpu-light to models list
- hermes-config-template: fix pgrep traps (exclude infisical wrapper) + add
  vault empty-key guard documentation in Rule 13
- litellm-api-keys: fix pgrep pattern in auditable check
- scripts/agent-health-check: fix pgrep to exclude infisical bash wrapper

Addresses issues found during relay inbox resolution session:
1. pgrep -f 'hermes_cli.main gateway run' matches both the real python
   gateway and the infisical bash wrapper, causing false health readings
2. infisical vault stores empty key silently — no guard/monitoring
3. gpu-dense/gpu-light stable aliases missing from baseline config
2026-07-18 17:39:46 +00:00

269 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
/root/scripts/agent-health-check.py — Consolidated Agent Health Verification
Single non-disruptive health check replacing 7 scattered scripts.
Verifies: LiteLLM keys, GPU port conflicts, agent Zulip streaming,
gateway liveness, and gateway log health. NEVER restarts anything.
Usage:
python3 /root/scripts/agent-health-check.py # Full check
python3 /root/scripts/agent-health-check.py --json # Machine-readable
python3 /root/scripts/agent-health-check.py --quiet # Only output on failure
Cron: */10 * * * * python3 /root/scripts/agent-health-check.py --quiet
"""
import subprocess, json, sys, os, time
from datetime import datetime
LITELLM = "http://192.168.68.116:80"
def _get_agent_key(agent_name):
"""Retrieve agent key from Infisical vault."""
try:
result = subprocess.run(
["infisical", "secrets", "get", "LITELLM_API_KEY",
"--project=agents", "--env=production", "--plain"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
# Fallback: try exporting all secrets
try:
result = subprocess.run(
["infisical", "export", "--project=agents", "--env=production",
"--format=dotenv"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
for line in result.stdout.splitlines():
if line.startswith(f"LITELLM_API_KEY_{agent_name.upper()}") or \
(line.startswith("LITELLM_API_KEY=") and agent_name == os.uname().nodename):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except Exception:
pass
return None
# Agent keys are pulled from Infisical vault at runtime.
# The 'key' field is populated dynamically below.
AGENTS = {
"tanko": {"ct": 112, "host": "192.168.68.122", "user": "jerome"},
"mumuni": {"ct": 114, "host": "192.168.68.123", "user": "root"},
"koby": {"ct": 111, "host": None, "user": None},
"koonimo": {"ct": 113, "host": None, "user": None},
}
# Inject keys from vault
for agent_name in AGENTS:
key = _get_agent_key(agent_name)
if key:
AGENTS[agent_name]["key"] = key
else:
AGENTS[agent_name]["key"] = None
GPU_HOSTS = {
"gpu-rtx3090 (.8)": {"host": "192.168.68.8", "port": 8080, "service": "llama-server"},
"gpu-rtx5070 (.110)": {"host": "192.168.68.110", "port": 8080, "service": "llama-server"},
"gpu-strixhalo (.15)": {"host": "192.168.68.15", "port": 8080, "service": "ornith-server"},
}
FAIL = []
def ssh(host, cmd, user="root"):
"""Execute a command on a remote host, return stdout or None."""
try:
result = subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
f"{user}@{host}", cmd],
capture_output=True, text=True, timeout=15
)
return result.stdout.strip() if result.returncode == 0 else None
except:
return None
def http_get(url, headers=None, timeout=5):
"""Return HTTP status code as string."""
try:
cmd = ["curl", "-sfk", "--connect-timeout", str(timeout), "-o", "/dev/null", "-w", "%{http_code}"]
if headers:
for k, v in headers.items():
cmd.extend(["-H", f"{k}: {v}"])
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout+3)
return result.stdout.strip() or "000"
except:
return "timeout"
def http_json(url, headers=None, timeout=5):
"""Return parsed JSON from URL, or None."""
try:
cmd = ["curl", "-sfk", "--connect-timeout", str(timeout)]
if headers:
for k, v in headers.items():
cmd.extend(["-H", f"{k}: {v}"])
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout+3)
return json.loads(result.stdout) if result.returncode == 0 and result.stdout else None
except:
return None
# ═══════════════════════════════════════════════════════════════════
# CHECK 1: LiteLLM Key Validation
# ═══════════════════════════════════════════════════════════════════
def check_keys():
for name, agent in AGENTS.items():
data = http_json(f"{LITELLM}/v1/models",
headers={"Authorization": f"Bearer {agent['key']}"})
if data and data.get("data"):
model = data["data"][0].get("id", "?")
print(f" ✅ {name}: key valid → {model}")
else:
print(f" ❌ {name}: KEY FAILURE — auth rejected or unreachable")
FAIL.append(f"key:{name}")
# ═══════════════════════════════════════════════════════════════════
# CHECK 2: GPU Port Conflict Detection
# ═══════════════════════════════════════════════════════════════════
def check_gpu_ports():
for label, gpu in GPU_HOSTS.items():
host = gpu["host"]
port = gpu["port"]
svc = gpu["service"]
svc_status = ssh(host, f"systemctl is-active {svc}")
port_owner = ssh(host, f"ss -tlnp 2>/dev/null | grep -Po ':{port}\\s+.*pid=\\K[0-9]+' | head -1")
if not svc_status:
print(f" ❌ {label}: UNREACHABLE")
FAIL.append(f"gpu-unreachable:{host}")
continue
if not port_owner:
print(f" ❌ {label}: PORT {port} NOT LISTENING (svc={svc_status})")
FAIL.append(f"gpu-no-port:{label}")
elif svc_status != "active":
svc_pid = ssh(host, f"systemctl show {svc} -p MainPID 2>/dev/null | cut -d= -f2")
if svc_pid and port_owner != svc_pid:
print(f" ❌ {label}: GHOST PROCESS — port owned by pid {port_owner}, svc pid {svc_pid} (svc={svc_status})")
FAIL.append(f"gpu-ghost:{label}:{port_owner}")
else:
print(f" ⚠️ {label}: svc={svc_status}, port owned by {port_owner}")
else:
# Verify health endpoint
health = ssh(host, f"curl -s --max-time 5 http://localhost:{port}/health")
if health and '"status":"ok"' in health:
print(f" ✅ {label}: healthy (pid={port_owner})")
elif health and '"status":"no slot available"' in health:
print(f" ✅ {label}: healthy (loading, pid={port_owner})")
elif health and '"error"' in health.lower():
print(f" ⚠️ {label}: error response (pid={port_owner}): {health[:80]}")
else:
print(f" ⚠️ {label}: unknown health (pid={port_owner}): {str(health)[:80]}")
# ═══════════════════════════════════════════════════════════════════
# CHECK 3: Agent Gateway Liveness + Streaming
# ═══════════════════════════════════════════════════════════════════
def check_agents():
for name, agent in AGENTS.items():
host = agent.get("host")
user = agent.get("user")
ct = agent["ct"]
if not host or not user:
print(f" ⬜ {name} (CT {ct}): cannot SSH — skip liveness check")
continue
# Gateway process (exclude the infisical bash wrapper that contains the same string)
pid = ssh(host, "pgrep -f 'hermes_cli.main gateway run' | grep -v infisical | head -1", user=user)
if not pid:
print(f" ❌ {name}: GATEWAY NOT RUNNING")
FAIL.append(f"gateway-down:{name}")
continue
# Gateway state file
state = ssh(host, "cat ~/.hermes/gateway_state.json 2>/dev/null", user=user)
if state:
try:
st = json.loads(state)
gw_state = st.get("gateway_state", "?")
zulip = st.get("platforms", {}).get("zulip", {}).get("state", "?")
except:
gw_state, zulip = "corrupt", "?"
else:
gw_state, zulip = "no-state-file", "?"
# Zulip streaming: does adapter have edit_message?
adapter_paths = [
"~/.hermes/plugins/zulip-platform/adapter.py",
"~/.hermes/plugins/platforms/zulip/adapter.py",
]
streaming = "no"
for p in adapter_paths:
has_edit = ssh(host, f"grep -c 'async def edit_message' {p} 2>/dev/null", user=user)
if has_edit and has_edit != "0":
streaming = "yes"
break
# Recent errors
recent_errors = ssh(host,
r"journalctl --user -u hermes-gateway --since '10 min ago' -o cat --no-pager 2>/dev/null "
r"| grep -ci 'error\|traceback\|exception\|401\|403\|500' || echo 0",
user=user)
recent_errors = (recent_errors or "0").strip().split("\n")[-1] # take last line
print(f" {'✅' if gw_state == 'running' and zulip == 'connected' else '⚠️'} "
f"{name}: gw={gw_state} zulip={zulip} streaming={streaming} "
f"errors_10m={recent_errors.strip() or '0'} pid={pid}")
# ═══════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════
def main():
quiet = "--quiet" in sys.argv
as_json = "--json" in sys.argv
if not quiet:
print(f"🏥 Agent Health Check — {datetime.now().strftime('%Y-%m-%d %H:%M UTC')}")
print()
print("🔑 LiteLLM Keys:")
check_keys()
print()
print("🎮 GPU Port Health:")
check_gpu_ports()
print()
print("🤖 Agent Gateways:")
check_agents()
if FAIL:
print(f"\n{len(FAIL)} FAILURE(S): {' | '.join(FAIL)}")
if quiet:
# In quiet mode, only print failures as a single alert line
print(f"ALERT agent-health:{','.join(FAIL)}")
elif not quiet:
print("\n✅ All checks passed")
if as_json:
print(json.dumps({"timestamp": datetime.now().isoformat(),
"failures": FAIL, "healthy": len(FAIL) == 0}))
sys.exit(1 if FAIL else 0)
if __name__ == "__main__":
main()