PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 11s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 24s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 23s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 2s
787 lines
37 KiB
Python
Executable File
787 lines
37 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
/root/scripts/agent-health-check.py — Consolidated Agent Health Verification v4
|
||
|
||
Verifies: LiteLLM keys (agent-specific), GPU port conflicts, agent Zulip streaming,
|
||
gateway liveness, gateway log health, CT liveness, config YAML integrity,
|
||
wrapper/CLI integrity, vault secret non-emptiness. 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
|
||
|
||
Changelog:
|
||
v2 (2026-07-26): Added CT liveness, config validation, wrapper integrity,
|
||
vault secret emptiness check. Fixed Koby/Koonimo SSH hosts and agent key
|
||
name format ({NAME}_LITELLM_API_KEY not LITELLM_API_KEY_{NAME}).
|
||
Fleet roster: tanko (.122), koby (.129), koonimo (.114), abiba (.24).
|
||
(v2 also carried a mumuni probe; see v5 — mumuni is no longer probed: she
|
||
moved to her own container, kagentz CT 105 / .14, and is monitored there.)
|
||
v3 (2026-09-08): GPU unit repoint verified live (.8 llama-chat-api.service,
|
||
.110 llama-server.service, .15 strix-server.service) — .8 was probing a stale
|
||
llama-server unit that reads inactive, producing false UNREACHABLE legs.
|
||
systemctl is-active no longer swallows non-zero exit as SSH failure.
|
||
Fixed UnboundLocalError on the abiba/koonimo gateway leg (pid unbound in the
|
||
summary f-string). Abiba's LiteLLM key now comes from /root/.pi/agent/env.sh
|
||
(#735 agent separation; creds moved out of shared /root/.bashrc).
|
||
v4 (2026-09-10): probe-drift round 2 (prose-contracts follow-up to #65/#66/#68).
|
||
abiba declared pi-only runtime — Hermes-era config/wrapper/gateway checks are
|
||
skipped (harness purge). koby declared report_only per the captain's
|
||
2026-08-17 ruling: every koby leg is detected and reported, never counted as a
|
||
fleet failure and never repaired. koby's PVE mapping corrected to storepve
|
||
(CT 111 tdunna lives on .6 — the old amdpve mapping produced a false
|
||
ct-unreachable). The wrapper infisical-path check had two stale-expectation
|
||
bugs: it read only the first 20 lines of the wrapper, so koonimo (whose
|
||
wrapper does reference /usr/bin/infisical, just past line 20) was falsely
|
||
FAILed as "path may be wrong"; and it treated the absence of any infisical
|
||
reference as a fault, though koby's wrapper sources the key from
|
||
~/.hermes/.env and never invokes infisical. The check now reads the full
|
||
wrapper body, accepts a no-infisical wrapper, and verifies that any absolute
|
||
infisical path the wrapper references actually exists. Report-only findings
|
||
are surfaced in a machine-readable `report_only` array in --json output,
|
||
separate from `failures`. Every run prints absolute execution provenance
|
||
(script + cwd) in the header, in the cron ALERT line, and in --json output so
|
||
a stale-consumer report is distinguishable from a fault at read time.
|
||
v5 (2026-09-10): roster correction only, no behavior change. mumuni was removed
|
||
from the AGENTS dict when she moved off this host onto her own container
|
||
(kagentz CT 105 on minipve, .14, dedicated `hermes` user) and is monitored
|
||
from her side. This script must not probe mumuni or .24 — the v2 changelog
|
||
roster line was the last reference still placing her at .24 / CT100.
|
||
v6 (2026-09-28): .8 GPU health probe now runs as `llmuser` instead of `root`.
|
||
Root SSH to .8 was lost when the guest was rebuilt, so every .8 leg read as
|
||
UNREACHABLE for a healthy host. llmuser owns llama-server and can read
|
||
`systemctl is-active`, `systemctl show -p MainPID`, and the :8080 pid.
|
||
.110 and .15 keep the default `root` user.
|
||
"""
|
||
|
||
import subprocess, json, sys, os, time, re, io, contextlib
|
||
from datetime import datetime
|
||
|
||
LITELLM = "http://192.168.68.116:80"
|
||
INFISICAL_PROJECT = "322fceab-39da-4854-a55a-568e76c0f13f"
|
||
INFISICAL_ENV = "prod"
|
||
|
||
# PVE node IPs for CT liveness checks
|
||
PVE_NODES = {
|
||
"amdpve": "192.168.68.15",
|
||
"minipve": "192.168.68.12",
|
||
"storepve": "192.168.68.6",
|
||
"acerpve": "192.168.68.9",
|
||
"ocupve": "192.168.68.5",
|
||
}
|
||
|
||
# Agent definitions: ct, host, user, pve_node, vault_key_name
|
||
AGENTS = {
|
||
"tanko": {"ct": 112, "host": "192.168.68.122", "user": "jerome", "pve": "minipve", "vault_key": "TANKO_LITELLM_API_KEY", "runtime": "dsh"},
|
||
# abiba = pi agent (.24) — no vault key; its LiteLLM key is read from its
|
||
# local env file (key_env below), not from the shared vault or .bashrc.
|
||
# runtime=pi: abiba has run pi-only since the harness purge. There is no
|
||
# Hermes gateway, no ~/.hermes/config.yaml and no hermes CLI wrapper on .24
|
||
# (the /root/.local/bin/hermes symlink is dangling), so the Hermes-era
|
||
# config/wrapper/gateway legs are skipped rather than reported as faults.
|
||
"abiba": {"ct": 100, "host": "192.168.68.24", "user": "root", "pve": "minipve",
|
||
"vault_key": None, "runtime": "pi",
|
||
"key_env": {"file": "/root/.pi/agent/env.sh", "var": "LITELLM_API_KEY"}},
|
||
# koby = report-only (captain's 2026-08-17 ruling, Rule 17): detect and
|
||
# report, NEVER repair, and never count against fleet failures. CT 111
|
||
# (tdunna) lives on storepve (.6) — verified live 2026-09-10; the previous
|
||
# amdpve mapping made `pct status 111` fail and read as ct-unreachable.
|
||
"koby": {"ct": 111, "host": "192.168.68.129", "user": "root", "pve": "storepve", "vault_key": "KOBY_LITELLM_API_KEY", "report_only": True},
|
||
"koonimo": {"ct": 113, "host": "192.168.68.114", "user": "root", "pve": "amdpve", "vault_key": "KOONIMO_LITELLM_API_KEY"},
|
||
}
|
||
|
||
# Systemd units verified live 2026-09-08 (systemctl list-units on each host):
|
||
# .8 rtx3090 (gpu-dense) -> llama-chat-api.service (active; the old
|
||
# llama-server.service unit file is stale/inactive — probing it read as
|
||
# UNREACHABLE for a healthy process)
|
||
# .110 rtx5070 (ocu-llm VM) -> llama-server.service (active)
|
||
# .15 strixhalo (amdpve) -> strix-server.service (active)
|
||
GPU_HOSTS = {
|
||
"gpu-rtx3090 (.8)": {"host": "192.168.68.8", "port": 8080, "service": "llama-chat-api.service", "user": "llmuser"},
|
||
"gpu-rtx5070 (.110)": {"host": "192.168.68.110", "port": 8080, "service": "llama-server.service"},
|
||
"gpu-strixhalo (.15)": {"host": "192.168.68.15", "port": 8080, "service": "strix-server.service"},
|
||
}
|
||
|
||
FAIL = []
|
||
REPORT_ONLY = []
|
||
|
||
|
||
def _fail(key, agent_name=None):
|
||
"""Record a failure, except for report-only agents.
|
||
|
||
Koby is report-only per the captain's 2026-08-17 ruling (Rule 17): its legs
|
||
are detected and reported, never repaired and never counted as fleet
|
||
failures. A red fleet alert on a known report-only leg is a false alarm.
|
||
Report-only findings are tracked separately so --json consumers can still
|
||
see them without them counting as fleet failures. Any non-report-only agent
|
||
(or a leg with no agent, e.g. GPU hosts) records normally.
|
||
"""
|
||
if agent_name and AGENTS.get(agent_name, {}).get("report_only"):
|
||
REPORT_ONLY.append(key)
|
||
print(f" 🔍 report-only ({agent_name}): {key} — reported, not counted/repaired")
|
||
return
|
||
FAIL.append(key)
|
||
|
||
|
||
INFISICAL_TOKEN = os.environ.get("INFISICAL_TOKEN")
|
||
if not INFISICAL_TOKEN:
|
||
# Fallback: read the shared vault token file
|
||
_token_path = os.path.expanduser("~/.infisical-token")
|
||
if os.path.isfile(_token_path):
|
||
try:
|
||
with open(_token_path) as _f:
|
||
INFISICAL_TOKEN = _f.read().strip()
|
||
except (OSError, UnicodeDecodeError):
|
||
pass
|
||
INFISICAL_API_URL = os.environ.get("INFISICAL_API_URL", "https://vault.sysloggh.net")
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────
|
||
|
||
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
|
||
|
||
def run_infisical(args, quiet=True):
|
||
"""Run infisical CLI with env-based auth, return stdout or None."""
|
||
env = os.environ.copy()
|
||
env["INFISICAL_API_URL"] = INFISICAL_API_URL
|
||
if INFISICAL_TOKEN:
|
||
env["INFISICAL_TOKEN"] = INFISICAL_TOKEN
|
||
try:
|
||
result = subprocess.run(
|
||
["/usr/bin/infisical"] + args,
|
||
capture_output=True, text=True, timeout=15, env=env
|
||
)
|
||
return result.stdout.strip() if result.returncode == 0 else None
|
||
except:
|
||
return None
|
||
|
||
# ── KEY LOOKUP FIX ───────────────────────────────────────────────────
|
||
|
||
def _get_agent_key(agent_name, vault_key_name):
|
||
"""Retrieve agent-specific key from Infisical vault.
|
||
|
||
Uses {NAME}_LITELLM_API_KEY format (e.g., TANKO_LITELLM_API_KEY,
|
||
KOONIMO_LITELLM_API_KEY) which matches actual vault key names.
|
||
"""
|
||
if not vault_key_name:
|
||
return None
|
||
|
||
# Primary: get the agent-specific key by name
|
||
key = run_infisical([
|
||
"secrets", "get", vault_key_name,
|
||
"--projectId=" + INFISICAL_PROJECT,
|
||
"--env=" + INFISICAL_ENV,
|
||
"--plain",
|
||
])
|
||
if key and key.startswith("sk-"):
|
||
return key
|
||
|
||
# Fallback: export all and search for the key name
|
||
try:
|
||
export = run_infisical([
|
||
"export",
|
||
"--projectId=" + INFISICAL_PROJECT,
|
||
"--env=" + INFISICAL_ENV,
|
||
"--format=dotenv",
|
||
])
|
||
if export:
|
||
for line in export.splitlines():
|
||
if line.startswith(vault_key_name + "="):
|
||
value = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||
if value.startswith("sk-"):
|
||
return value
|
||
except:
|
||
pass
|
||
|
||
return None
|
||
|
||
|
||
def _read_env_export(path, var):
|
||
"""Parse `export VAR=value` (or `VAR=value`) out of a local env file.
|
||
|
||
#735 agent separation (2026-09-06): agent creds moved out of the shared
|
||
/root/.bashrc into per-agent env files under /root/.pi/agent/ (bashrc's
|
||
source line keeps abiba shells resolving them, but the file of record is
|
||
env.sh). Do NOT fall back to /root/.bashrc here: desktop (.200) SSH
|
||
sessions override LITELLM_API_KEY with mumuni's key, so sourcing bashrc
|
||
would validate the wrong identity.
|
||
"""
|
||
try:
|
||
with open(os.path.expanduser(path)) as _f:
|
||
for line in _f:
|
||
line = line.strip()
|
||
if not (line.startswith("export " + var + "=") or line.startswith(var + "=")):
|
||
continue
|
||
value = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||
if value:
|
||
return value
|
||
except (OSError, UnicodeDecodeError):
|
||
pass
|
||
return None
|
||
|
||
|
||
def load_agent_keys():
|
||
"""Populate AGENTS[*]["key"] from the vault or the agent's local env file.
|
||
|
||
Called from main(), not at import: keeping this out of module scope lets the
|
||
module be imported (and unit tested) without live vault/SSH access. Vault
|
||
format is {NAME}_LITELLM_API_KEY (project 322fceab-39da-4854-a55a-568e76c0f13f,
|
||
env prod); abiba has no vault key and reads LITELLM_API_KEY from its local
|
||
/root/.pi/agent/env.sh (moved there from /root/.bashrc in #735).
|
||
"""
|
||
for agent_name in AGENTS:
|
||
info = AGENTS[agent_name]
|
||
key = _get_agent_key(agent_name, info.get("vault_key"))
|
||
if not key and info.get("key_env"):
|
||
key = _read_env_export(info["key_env"]["file"], info["key_env"]["var"])
|
||
AGENTS[agent_name]["key"] = key
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# CHECK 1: LiteLLM Key Validation (agent-specific keys)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def check_keys():
|
||
for name, agent in AGENTS.items():
|
||
key = agent.get("key")
|
||
if not key:
|
||
print(f" ❌ {name}: NO KEY FOUND (vault/env empty or unreachable)")
|
||
_fail(f"key:{name}:no-key", name)
|
||
continue
|
||
data = http_json(f"{LITELLM}/v1/models",
|
||
headers={"Authorization": f"Bearer {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(f"key:{name}", name)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# CHECK 2: GPU Port Conflict Detection (unit names verified live 2026-09-08)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def check_gpu_ports():
|
||
for label, gpu in GPU_HOSTS.items():
|
||
host = gpu["host"]
|
||
port = gpu["port"]
|
||
svc = gpu["service"]
|
||
user = gpu.get("user", "root") # default root, overridden per-host where needed
|
||
|
||
# `systemctl is-active` exits non-zero when the unit is inactive or
|
||
# missing, which the ssh() helper would swallow as an SSH failure and
|
||
# report as UNREACHABLE. `|| true` keeps the real state word so we can
|
||
# tell "unit inactive" from "host unreachable".
|
||
svc_status = ssh(host, f"systemctl is-active {svc} || true", user=user)
|
||
port_owner = ssh(host, f"ss -tlnp 2>/dev/null | grep -Po ':{port}\\s+.*pid=\\K[0-9]+' | head -1", user=user)
|
||
|
||
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", user=user)
|
||
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:
|
||
health = ssh(host, f"curl -s --max-time 5 http://localhost:{port}/health", user=user)
|
||
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 (now covers all agents)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def _ssh_retry(host, cmd, user="root", timeout=15, retry_timeout=25, label=""):
|
||
"""SSH with one retry at a longer timeout.
|
||
|
||
Returns (stdout_or_None, probe_failed_bool, fail_kind).
|
||
When probe_failed is True, fail_kind is one of: timeout, ssh-failed.
|
||
"""
|
||
import subprocess as _sp
|
||
def _attempt(tmo, conn_tmo):
|
||
try:
|
||
r = _sp.run(
|
||
["ssh", "-o", "StrictHostKeyChecking=no", "-o", f"ConnectTimeout={conn_tmo}",
|
||
f"{user}@{host}", cmd],
|
||
capture_output=True, text=True, timeout=tmo)
|
||
return r.stdout.strip() if r.returncode == 0 else None
|
||
except _sp.TimeoutExpired:
|
||
return "__timeout__"
|
||
except:
|
||
return None
|
||
result = _attempt(timeout, 8)
|
||
if result is None or result == "__timeout__":
|
||
kind = "timeout" if result == "__timeout__" else "ssh-failed"
|
||
prefix = f"{label} " if label else ""
|
||
print(f" probe-failed: {prefix}ssh {user}@{host} — {kind} (retrying at {retry_timeout}s…)")
|
||
result = _attempt(retry_timeout, 15)
|
||
if result is None or result == "__timeout__":
|
||
kind = "timeout" if result == "__timeout__" else "ssh-failed"
|
||
return None, True, kind
|
||
return result, False, None
|
||
|
||
|
||
def check_agents():
|
||
for name, agent in AGENTS.items():
|
||
host = agent.get("host")
|
||
user = agent.get("user")
|
||
ct = agent["ct"]
|
||
report_only = agent.get("report_only", False)
|
||
|
||
# Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — it no longer runs a
|
||
# Hermes gateway, so skip the Hermes gateway/state/streaming/journal checks.
|
||
# Non-Hermes runtimes have no gateway to probe. dsh = Tanko since
|
||
# 2026-08-27; pi = abiba since the harness purge (.24 is pi-only).
|
||
if agent.get("runtime") in ("dsh", "pi"):
|
||
is_dsh = agent.get("runtime") == "dsh"
|
||
label = "DSH (DeepSeek Harness)" if is_dsh else "pi-only runtime"
|
||
since = "since 2026-08-27" if is_dsh else "since the harness purge"
|
||
live, probe_failed, fail_kind = _ssh_retry(host, "true", user=user)
|
||
if probe_failed:
|
||
print(f" ❌ {name}: {label} — probe-failed: ssh {user}@{host} {fail_kind} "
|
||
f"(retried at 25s: also {fail_kind}) [CT {ct}]")
|
||
_fail(f"probe-failed:{name}:{fail_kind}", name)
|
||
else:
|
||
print(f" ✅ {name}: {label} — no Hermes gateway {since} "
|
||
f"(ssh {user}@{host} OK, CT {ct})")
|
||
continue
|
||
|
||
if not host or not user:
|
||
print(f" ⬜ {name} (CT {ct}): cannot SSH — skip liveness check")
|
||
continue
|
||
|
||
# Resolve the Hermes gateway PID with retry. The probe target is
|
||
# explicit: ssh {user}@{host} pgrep -f hermes gateway.
|
||
pid, probe_failed, fail_kind = _ssh_retry(
|
||
host, "pgrep -f '[h]ermes_cli.main gateway run' | grep -v infisical | head -1", user=user)
|
||
if not pid and not probe_failed:
|
||
pid, probe_failed, fail_kind = _ssh_retry(
|
||
host, "pgrep -f '[h]ermes.*gateway' | grep -v infisical | grep -v bash | head -1", user=user)
|
||
if not pid and not probe_failed:
|
||
pid = "?"
|
||
|
||
if probe_failed:
|
||
print(f" ❌ {name}: probe-failed: ssh {user}@{host} {fail_kind} "
|
||
f"(retried at 25s: also {fail_kind}) [CT {ct}] — gateway status UNDETERMINED")
|
||
_fail(f"probe-failed:{name}:{fail_kind}", name)
|
||
continue
|
||
|
||
# ⛔ KOBY IS NEVER REPAIRED — diagnostic only (captain's 2026-08-17 ruling)
|
||
if report_only:
|
||
if pid == "?":
|
||
print(f" 🔍 {name}: REPORT-ONLY — probe: ssh {user}@{host} pgrep hermes-gateway "
|
||
f"-> no process found (reported only, NOT counted) [CT {ct}]")
|
||
_fail(f"gateway-down:{name}", name)
|
||
else:
|
||
print(f" 🔍 {name}: REPORT-ONLY — probe: ssh {user}@{host} pgrep hermes-gateway "
|
||
f"-> pid={pid} (running, reported only, NOT repaired) [CT {ct}]")
|
||
continue # Skip the rest of the check for Koby
|
||
|
||
# Gateway state file
|
||
state, _, _ = _ssh_retry(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 check
|
||
adapter_paths = [
|
||
"~/.hermes/plugins/zulip-platform/adapter.py",
|
||
"~/.hermes/plugins/platforms/zulip/adapter.py",
|
||
]
|
||
streaming = "no"
|
||
for p in adapter_paths:
|
||
has_edit, _, _ = _ssh_retry(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_retry(
|
||
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]
|
||
|
||
print(f" {'✅' if gw_state == 'running' and zulip == 'connected' else '⚠️'} "
|
||
f"{name}: probe: ssh {user}@{host} — gw={gw_state} zulip={zulip} "
|
||
f"streaming={streaming} errors_10m={recent_errors.strip() or '0'} pid={pid} [CT {ct}]")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# CHECK 4: CT Liveness (NEW)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def check_ct_liveness():
|
||
"""Check that all agent CTs are running on their PVE nodes."""
|
||
for name, agent in AGENTS.items():
|
||
ct = agent["ct"]
|
||
pve_node = agent.get("pve")
|
||
if not pve_node:
|
||
print(f" ⬜ {name} (CT {ct}): no PVE node mapped — skip")
|
||
continue
|
||
|
||
pve_ip = PVE_NODES.get(pve_node)
|
||
if not pve_ip:
|
||
print(f" ⬜ {name}: unknown PVE node '{pve_node}' — skip")
|
||
continue
|
||
|
||
status = ssh(pve_ip, f"pct status {ct} 2>/dev/null", user="root")
|
||
if not status:
|
||
print(f" ❌ {name} (CT {ct} on {pve_node}): PVE UNREACHABLE")
|
||
_fail(f"ct-unreachable:{name}:{pve_ip}", name)
|
||
elif "running" in status:
|
||
print(f" ✅ {name} (CT {ct} on {pve_node}): running")
|
||
elif "stopped" in status:
|
||
print(f" ❌ {name} (CT {ct} on {pve_node}): STOPPED")
|
||
_fail(f"ct-stopped:{name}", name)
|
||
else:
|
||
print(f" ⚠️ {name} (CT {ct} on {pve_node}): {status.strip()}")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# CHECK 5: Config YAML Integrity (NEW)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def check_config_integrity():
|
||
"""Verify agent config.yaml parses as valid YAML."""
|
||
for name, agent in AGENTS.items():
|
||
# Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — no Hermes config.yaml.
|
||
if agent.get("runtime") == "dsh":
|
||
print(f" ⏭️ {name}: DSH — no Hermes config.yaml since 2026-08-27")
|
||
continue
|
||
if agent.get("runtime") == "pi":
|
||
print(f" ⏭️ {name}: pi-only runtime — no Hermes config.yaml since the harness purge")
|
||
continue
|
||
host = agent.get("host")
|
||
user = agent.get("user")
|
||
if not host or not user:
|
||
print(f" ⬜ {name}: cannot SSH — skip config check")
|
||
continue
|
||
|
||
# Check YAML parses
|
||
yaml_ok = ssh(host,
|
||
"python3 -c "
|
||
'"import yaml; yaml.safe_load(open(\'/root/.hermes/config.yaml\')); print(\'OK\')" '
|
||
"2>&1 || echo 'FAIL'",
|
||
user=user)
|
||
if not yaml_ok:
|
||
print(f" ❌ {name}: SSH UNREACHABLE (config check skipped)")
|
||
_fail(f"config-unreachable:{name}", name)
|
||
elif "OK" in yaml_ok:
|
||
print(f" ✅ {name}: config.yaml valid YAML")
|
||
else:
|
||
print(f" ❌ {name}: config.yaml YAML ERROR — {yaml_ok[:120]}")
|
||
_fail(f"config-yaml-error:{name}", name)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# CHECK 6: Wrapper/CLI Integrity (NEW)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def _infisical_invocation_paths(wrapper_body):
|
||
"""Absolute infisical paths the wrapper actually invokes.
|
||
|
||
Only executed (non-comment) lines count, and only a path followed by a real
|
||
infisical subcommand (e.g. `/usr/bin/infisical run`) is treated as an
|
||
invocation. A note such as `# migrated from /usr/local/bin/infisical` is
|
||
prose, not a call, so it must not manufacture a dangling-path false alarm.
|
||
"""
|
||
paths = []
|
||
for line in wrapper_body.splitlines():
|
||
code = line.split("#", 1)[0]
|
||
for _m in re.finditer(
|
||
r"(/[A-Za-z0-9._/-]*infisical)\s+(?:run|export|secrets|login|logout)\b",
|
||
code,
|
||
):
|
||
if _m.group(1) not in paths:
|
||
paths.append(_m.group(1))
|
||
return paths
|
||
|
||
|
||
def check_wrapper_integrity():
|
||
"""Verify the hermes CLI wrapper exists and can reach hermes-real."""
|
||
for name, agent in AGENTS.items():
|
||
# Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — no hermes CLI wrapper.
|
||
if agent.get("runtime") == "dsh":
|
||
print(f" ⏭️ {name}: DSH — no hermes CLI wrapper since 2026-08-27")
|
||
continue
|
||
if agent.get("runtime") == "pi":
|
||
print(f" ⏭️ {name}: pi-only runtime — no hermes CLI wrapper since the harness purge")
|
||
continue
|
||
host = agent.get("host")
|
||
user = agent.get("user")
|
||
if not host or not user:
|
||
print(f" ⬜ {name}: cannot SSH — skip wrapper check")
|
||
continue
|
||
|
||
# Check wrapper exists
|
||
wrapper = ssh(host, "ls -la /root/.local/bin/hermes 2>/dev/null", user=user)
|
||
if not wrapper:
|
||
# Check alternate wrapper locations
|
||
wrapper = ssh(host, "which hermes 2>/dev/null; command -v hermes 2>/dev/null", user=user)
|
||
if not wrapper:
|
||
print(f" ❌ {name}: NO HERMES CLI WRAPPER FOUND")
|
||
_fail(f"wrapper-missing:{name}", name)
|
||
continue
|
||
else:
|
||
print(f" ⚠️ {name}: hermes at {wrapper.strip()} (not ~/.local/bin/hermes)")
|
||
|
||
# Credential-injection mechanism. The Hermes-era wrapper injected creds
|
||
# with `/usr/bin/infisical run`, but the mechanism is not required to be
|
||
# infisical at all: koby's wrapper sources the key from ~/.hermes/.env
|
||
# and never mentions infisical, which is valid. The old check read only
|
||
# the first 20 lines, so koonimo's wrapper — which DOES reference
|
||
# /usr/bin/infisical, just past line 20 — false-failed as "path may be
|
||
# wrong". Read the full body, accept a no-infisical wrapper, and verify
|
||
# the absolute infisical path(s) the wrapper actually invokes. Only
|
||
# executed (non-comment) lines count: a comment or dead prose mentioning
|
||
# a removed path (litellm-api-keys.prose.md documents
|
||
# `rm -f /usr/local/bin/infisical`) must neither produce a dangling path
|
||
# nor trigger the PATH check — it is not an invocation.
|
||
wrapper_body = ssh(host, "cat /root/.local/bin/hermes 2>/dev/null", user=user) or ""
|
||
wrapper_code = "\n".join(line.split("#", 1)[0] for line in wrapper_body.splitlines())
|
||
invoked_paths = _infisical_invocation_paths(wrapper_body)
|
||
if "infisical" in wrapper_code:
|
||
if invoked_paths:
|
||
missing = []
|
||
for _p in invoked_paths:
|
||
_exists = ssh(host, f"test -x {_p} && echo OK || echo MISS", user=user)
|
||
if not _exists or _exists.strip().splitlines()[-1] != "OK":
|
||
missing.append(_p)
|
||
if len(missing) == len(invoked_paths):
|
||
inf_actual = ssh(host, "command -v infisical 2>/dev/null", user=user)
|
||
suffix = f" (infisical at {inf_actual})" if inf_actual else ""
|
||
print(f" ❌ {name}: wrapper invokes infisical via missing path(s) "
|
||
f"{', '.join(missing)}{suffix}")
|
||
_fail(f"wrapper-infisical-path:{name}", name)
|
||
elif missing:
|
||
print(f" ⚠️ {name}: wrapper has an unused/missing infisical path "
|
||
f"({', '.join(missing)}) but a working invocation — informational")
|
||
elif "/usr/bin/infisical" not in invoked_paths:
|
||
print(f" ⚠️ {name}: wrapper infisical path differs "
|
||
f"({', '.join(invoked_paths)}) — informational")
|
||
else:
|
||
print(f" ✅ {name}: wrapper infisical path OK")
|
||
else:
|
||
inf_actual = ssh(host, "command -v infisical 2>/dev/null", user=user)
|
||
if not inf_actual:
|
||
print(f" ❌ {name}: wrapper invokes infisical but the binary is MISSING")
|
||
_fail(f"wrapper-no-infisical:{name}", name)
|
||
else:
|
||
print(f" ✅ {name}: wrapper infisical resolves via PATH ({inf_actual})")
|
||
else:
|
||
print(f" ℹ️ {name}: wrapper resolves creds without infisical (e.g. ~/.hermes/.env) — OK")
|
||
|
||
# Check hermes-real exists
|
||
hermes_real = ssh(host,
|
||
"ls -la /root/.local/bin/hermes-real 2>/dev/null || echo MISS",
|
||
user=user)
|
||
if not hermes_real or hermes_real.strip() == "MISS":
|
||
# Check venv path
|
||
hermes_real = ssh(host,
|
||
"ls -la /usr/local/lib/hermes-agent/venv/bin/hermes 2>/dev/null || echo MISS",
|
||
user=user)
|
||
if not hermes_real or hermes_real.strip() == "MISS":
|
||
print(f" ❌ {name}: hermes-real NOT FOUND (wrapper broken)")
|
||
_fail(f"wrapper-no-hermes-real:{name}", name)
|
||
else:
|
||
print(f" ✅ {name}: hermes-real at alt path")
|
||
|
||
# Check the .env file has the key
|
||
env_has_key = ssh(host,
|
||
"grep -c 'LITELLM_API_KEY' /root/.hermes/.env 2>/dev/null || echo 0",
|
||
user=user)
|
||
if env_has_key and env_has_key.strip() not in ("", "0"):
|
||
print(f" ✅ {name}: wrapper + .env key present")
|
||
else:
|
||
print(f" ⚠️ {name}: .env may be missing LITELLM_API_KEY entry")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# CHECK 7: Vault Secret Non-Emptiness (NEW)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def check_vault_secrets():
|
||
"""Verify agent-specific vault secrets are non-empty and start with sk-."""
|
||
for name, agent in AGENTS.items():
|
||
vault_key_name = agent.get("vault_key")
|
||
if not vault_key_name:
|
||
continue
|
||
|
||
key = agent.get("key")
|
||
if not key:
|
||
print(f" ❌ {name}: vault secret {vault_key_name} MISSING or EMPTY")
|
||
_fail(f"vault-empty:{name}:{vault_key_name}", name)
|
||
elif not key.startswith("sk-"):
|
||
print(f" ❌ {name}: vault secret {vault_key_name} WRONG FORMAT (starts '{key[:8]}...')")
|
||
_fail(f"vault-bad-format:{name}:{vault_key_name}", name)
|
||
else:
|
||
print(f" ✅ {name}: vault {vault_key_name}=sk-...{key[-4:]}")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# DEPLOY: copy updated script to /root/scripts/ on local host
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def deploy_self():
|
||
"""Copy this script to /root/scripts/agent-health-check.py if out of date."""
|
||
dest = "/root/scripts/agent-health-check.py"
|
||
try:
|
||
with open(__file__, "r") as f:
|
||
current = f.read()
|
||
if os.path.isfile(dest):
|
||
with open(dest, "r") as f:
|
||
existing = f.read()
|
||
if current == existing:
|
||
return # Already deployed
|
||
# Write new version
|
||
with open(dest, "w") as f:
|
||
f.write(current)
|
||
os.chmod(dest, 0o755)
|
||
print(f" 📦 Deployed updated script to {dest}")
|
||
except:
|
||
pass # Not fatal if deploy fails
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# MAIN
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def _run_checks():
|
||
print("🔑 LiteLLM Keys:")
|
||
check_keys()
|
||
print()
|
||
|
||
print("🎮 GPU Port Health:")
|
||
check_gpu_ports()
|
||
print()
|
||
|
||
print("🤖 Agent Gateways:")
|
||
check_agents()
|
||
print()
|
||
|
||
print("🖥️ CT Liveness:")
|
||
check_ct_liveness()
|
||
print()
|
||
|
||
print("📝 Config Integrity:")
|
||
check_config_integrity()
|
||
print()
|
||
|
||
print("🔌 Wrapper/CLI Integrity:")
|
||
check_wrapper_integrity()
|
||
print()
|
||
|
||
print("🔐 Vault Secrets:")
|
||
check_vault_secrets()
|
||
|
||
|
||
def main():
|
||
quiet = "--quiet" in sys.argv
|
||
as_json = "--json" in sys.argv
|
||
|
||
# Self-deploy to canonical location
|
||
if not quiet and "--no-deploy" not in sys.argv:
|
||
deploy_self()
|
||
|
||
# Provenance: a report is only actionable if the reader can tell WHICH copy
|
||
# of this script produced it. A normal run carries it in the header, --json
|
||
# carries it for machine consumers, and the cron ALERT line carries it on
|
||
# failure. --quiet is documented as "only output on failure", so the header
|
||
# is emitted only when not quiet and a healthy quiet run stays silent.
|
||
script_path = os.path.abspath(__file__)
|
||
cwd = os.getcwd()
|
||
|
||
if quiet:
|
||
captured = io.StringIO()
|
||
with contextlib.redirect_stdout(captured):
|
||
load_agent_keys()
|
||
_run_checks()
|
||
if FAIL:
|
||
sys.stdout.write(captured.getvalue())
|
||
else:
|
||
print(f"🏥 Agent Health Check v4 — {datetime.now().strftime('%Y-%m-%d %H:%M UTC')}")
|
||
print(f"📍 executed from: script={script_path} cwd={cwd}")
|
||
print()
|
||
load_agent_keys()
|
||
_run_checks()
|
||
|
||
if FAIL:
|
||
print(f"\n❌ {len(FAIL)} FAILURE(S): {' | '.join(FAIL)}")
|
||
if quiet:
|
||
print(f"ALERT agent-health:{','.join(FAIL)} script={script_path} cwd={cwd}")
|
||
elif not quiet:
|
||
print("\n✅ All checks passed")
|
||
|
||
if as_json:
|
||
print(json.dumps({"timestamp": datetime.now().isoformat(),
|
||
"execution_path": script_path, "cwd": cwd,
|
||
"failures": FAIL, "report_only": REPORT_ONLY,
|
||
"healthy": len(FAIL) == 0}))
|
||
|
||
sys.exit(1 if FAIL else 0)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|