feat: add health watchdog, key auto-sync, analytics, Discord notifications, trivial-PR merge
- health-check.py: model health watchdog (silent when healthy, alert on 2+ consecutive failures) — catches stale-key/model-breakage like 2026-08-04 - sync-key.py: systemd ExecStartPre syncs 9router key from BWS to disk, prevents silent 401s after key rotation - run_server.py: CONFIG__ANALYTICS_FOLDER + /api/metrics (Prometheus) + /api/analytics (JSON) + /api/v1/notify_review (Discord webhook) - trivial_merge.py: trivial-PR fast-path (docs/deps/tiny diffs) approve+merge - auto_merge_bot.py: Discord notifications on merge, trivial integration - flake.nix: ship pr-agent-sync-key + pr-agent-health-check binaries
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR-Agent Model Health Watchdog
|
||||
===============================
|
||||
Runs every 10 minutes via Hermes no_agent cron. Tests the exact model config
|
||||
the pr-agent server uses (primary + fallbacks) against 9router via litellm.
|
||||
|
||||
Output contract (no_agent cron):
|
||||
- OK → empty stdout (silent, $0 idle)
|
||||
- FAIL → one-line alert + detail (delivered to Discord/home channel)
|
||||
"""
|
||||
import os, sys, json, hashlib, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
BWS_SECRET_ID = "2aef2194-971d-4dae-99dd-b49a0041f97c"
|
||||
ROUTER_BASE = "https://9router.asepharyana.my.id/v1"
|
||||
PRIMARY = "openai/claude-opus-4-8"
|
||||
FALLBACKS = ["openai/ATLAS", "openai/gemini", "openai/text", "openai/deepseek-v4-flash-free"]
|
||||
CONSECUTIVE_FAIL_FILE = Path("/tmp/pr-agent-health-fail-count")
|
||||
|
||||
# ── key from BWS ────────────────────────────────────────────────────────────
|
||||
def get_key() -> str:
|
||||
token = os.environ.get("BWS_ACCESS_TOKEN", "")
|
||||
if not token and Path("/etc/bws-token").is_file():
|
||||
token = Path("/etc/bws-token").read_text().strip()
|
||||
if not token:
|
||||
return ""
|
||||
env = {**os.environ, "BWS_ACCESS_TOKEN": token}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["/usr/local/bin/bws", "secret", "get", BWS_SECRET_ID, "--output", "env"],
|
||||
capture_output=True, text=True, timeout=30, env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return ""
|
||||
# Value is shell-quoted KEY="value" — take first line only. BWS sometimes
|
||||
# appends "# one or more secrets have been commented-out..." after a
|
||||
# problematic key rename; only the first line is the real key value.
|
||||
line = r.stdout.split("\n")[0]
|
||||
if "=" not in line:
|
||||
return ""
|
||||
val = line.split("=", 1)[1].strip()
|
||||
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
||||
val = val[1:-1]
|
||||
return val
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ── health check ────────────────────────────────────────────────────────────
|
||||
def check_model(model: str, key: str) -> tuple:
|
||||
"""Returns (ok: bool, detail: str). Uses raw HTTP (no litellm dependency).
|
||||
|
||||
NOTE: litellm strips the 'openai/' provider prefix before sending the
|
||||
request body. 9router resolves bare aliases (e.g. 'claude-opus-4-8') to
|
||||
its own routing; WITH the prefix it tries the 'openai' provider upstream,
|
||||
which has no credentials → 404 'No active credentials for provider: openai'.
|
||||
So we strip the prefix here to mirror exactly what the server sends.
|
||||
"""
|
||||
bare = model.split("/", 1)[-1] if "/" in model else model
|
||||
import urllib.request, urllib.error
|
||||
body = json.dumps({
|
||||
"model": bare,
|
||||
"messages": [{"role": "user", "content": "Reply with the single word OK"}],
|
||||
"max_tokens": 10,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{ROUTER_BASE}/chat/completions",
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return r.status == 200, f"HTTP {r.status}"
|
||||
except urllib.error.HTTPError as e:
|
||||
err = e.read().decode(errors="replace")[:160].replace("\n", " ")
|
||||
return False, f"HTTP {e.code}: {err}"
|
||||
except Exception as e:
|
||||
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
key = get_key()
|
||||
if not key:
|
||||
print("⚠️ pr-agent health: cannot fetch router key from BWS (bws unavailable)")
|
||||
return 1
|
||||
|
||||
failures = []
|
||||
ok_primary, detail = check_model(PRIMARY, key)
|
||||
if not ok_primary:
|
||||
failures.append(f"primary {PRIMARY} → {detail}")
|
||||
for fb in FALLBACKS:
|
||||
ok, d = check_model(fb, key)
|
||||
if not ok:
|
||||
failures.append(f"fallback {fb} → {d}")
|
||||
|
||||
if not failures:
|
||||
# healthy — clear counter, stay silent
|
||||
CONSECUTIVE_FAIL_FILE.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
# At least one model failed. Count consecutive failures to avoid flapping.
|
||||
n = 1
|
||||
if CONSECUTIVE_FAIL_FILE.exists():
|
||||
try:
|
||||
n = int(CONSECUTIVE_FAIL_FILE.read_text().strip()) + 1
|
||||
except ValueError:
|
||||
n = 1
|
||||
CONSECUTIVE_FAIL_FILE.write_text(str(n))
|
||||
|
||||
if n < 2:
|
||||
# first failure — could be transient, stay quiet
|
||||
return 0
|
||||
|
||||
detail = " | ".join(failures)
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()[:8]
|
||||
print(f"🚨 pr-agent MODELS FAILING ({n} consecutive checks)\n{detail}\nkey hash {key_hash}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user