Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy (AI Agent)
fa2ad89e40 fix(rca): VPS Gitea heartbeat for Ezra/Bezalel @mentions (#579)
Some checks failed
Smoke Test / smoke (pull_request) Failing after 12s
Root cause: Mac-local dispatch queue has no VPS reader; no VPS-native
polling existed on Ezra/Bezalel boxes.

Fix: standalone polling heartbeat per VPS box. Polls Gitea every 5 min
for @mentions and assignments, dispatches to local hermes chat, tracks
seen events for deduplication.

- scripts/vps-gitea-heartbeat.py — polling script
- scripts/vps-gitea-heartbeat-install.sh — cron/systemd installer
- skills/autonomous-ai-agents/vps-gitea-heartbeat/SKILL.md

Closes #579
2026-04-13 21:25:20 -04:00
3 changed files with 314 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
AGENT="${1:-}"; METHOD="${2:-cron}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
HB="$SCRIPT_DIR/vps-gitea-heartbeat.py"
[ -z "$AGENT" ] && { echo "Usage: $0 <ezra|bezalel> [cron|systemd]"; exit 1; }
echo "Installing gitea-heartbeat for @$AGENT ($METHOD)"
mkdir -p "$HOME/.hermes/logs/gitea-heartbeat"
if [ "$METHOD" = "systemd" ]; then
SVC="/etc/systemd/system/gitea-heartbeat-${AGENT}.service"
cat > "$SVC" <<EOF
[Unit]
Description=Gitea Heartbeat @${AGENT}
After=network.target
[Service]
Type=simple
User=$(whoami)
ExecStart=$(which python3) $HB --agent $AGENT --daemon
Restart=always
RestartSec=30
Environment=HOME=$HOME
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now "gitea-heartbeat-${AGENT}"
echo "Started. Status: sudo systemctl status gitea-heartbeat-${AGENT}"
else
LINE="*/5 * * * * $(which python3) $HB --agent $AGENT --once >> $HOME/.hermes/logs/gitea-heartbeat/cron.log 2>&1"
(crontab -l 2>/dev/null | grep -v "gitea-heartbeat.*--agent $AGENT"; echo "$LINE") | crontab -
echo "Crontab installed."
fi
echo "Test: python3 $HB --agent $AGENT --once"

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
VPS Gitea Heartbeat — Poll Gitea for @mentions and dispatch locally.
Runs on Ezra or Bezalel VPS. Polls Gitea every 5 minutes for:
- Issue comments mentioning this agent (@ezra, @bezalel, etc.)
- Issues assigned to this agent
Dispatches to local `hermes chat` with the issue context.
Usage:
python3 vps-gitea-heartbeat.py --agent ezra --once
python3 vps-gitea-heartbeat.py --agent bezalel --daemon
"""
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
GITEA = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
TOKEN_FILE = os.environ.get("GITEA_TOKEN_FILE", os.path.expanduser("~/.hermes/gitea_token_vps"))
STATE_FILE_TPL = "~/.hermes/gitea-heartbeat-{agent}.json"
LOG_DIR = os.path.expanduser("~/.hermes/logs/gitea-heartbeat")
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "300"))
DISPATCH_TIMEOUT = int(os.environ.get("DISPATCH_TIMEOUT", "600"))
KNOWN_AGENTS = {
"timmy", "ezra", "bezalel", "allegro", "claude", "gemini",
"grok", "kimi", "fenrir", "manus", "perplexity", "rockachopa",
}
REPOS = [
"Timmy_Foundation/hermes-agent",
"Timmy_Foundation/the-nexus",
"Timmy_Foundation/timmy-config",
"Timmy_Foundation/timmy-home",
"Timmy_Foundation/the-beacon",
]
def log(msg, level="INFO"):
ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
line = f"[{ts}] [{level}] {msg}"
print(line)
try:
os.makedirs(LOG_DIR, exist_ok=True)
with open(os.path.join(LOG_DIR, f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.log"), "a") as f:
f.write(line + "\n")
except Exception:
pass
def load_token():
try:
return Path(TOKEN_FILE).read_text().strip()
except Exception:
return os.environ.get("GITEA_TOKEN", "")
def load_state(agent):
path = os.path.expanduser(STATE_FILE_TPL.format(agent=agent))
try:
return json.loads(Path(path).read_text())
except Exception:
return {"seen": {}, "last_run": None}
def save_state(agent, state):
path = os.path.expanduser(STATE_FILE_TPL.format(agent=agent))
os.makedirs(os.path.dirname(path), exist_ok=True)
state["last_run"] = datetime.now(timezone.utc).isoformat()
Path(path).write_text(json.dumps(state, indent=2))
def api(path, token):
req = urllib.request.Request(
f"{GITEA}/api/v1{path}",
headers={"Authorization": f"token {token}"},
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read())
except Exception as e:
log(f"API error: {path}{e}", "WARN")
return None
def dispatch(prompt, agent):
log(f"Dispatching to hermes chat for @{agent}")
try:
r = subprocess.run(
["hermes", "chat", "--quiet", "-q", prompt],
capture_output=True, text=True, timeout=DISPATCH_TIMEOUT,
)
if r.returncode == 0:
log("Dispatch complete")
else:
log(f"Dispatch exit {r.returncode}: {r.stderr[:200]}", "WARN")
except subprocess.TimeoutExpired:
log("Dispatch timed out", "WARN")
except Exception as e:
log(f"Dispatch error: {e}", "ERROR")
def poll(agent, token):
state = load_state(agent)
seen = state.setdefault("seen", {})
tasks = []
for repo in REPOS:
issues = api(f"/repos/{repo}/issues?state=open&limit=25&sort=recentupdate", token)
if not isinstance(issues, list):
continue
for issue in issues:
num = issue.get("number", 0)
key = f"{repo}#{num}"
assignee = ((issue.get("assignee") or {}).get("login") or "").lower()
comments = api(f"/repos/{repo}/issues/{num}/comments?limit=10&sort=created", token) or []
for c in comments:
ck = f"{key}/c{c['id']}"
if ck in seen:
continue
seen[ck] = True
commenter = ((c.get("user") or {}).get("login") or "").lower()
body = (c.get("body", "") or "").lower()
if commenter == agent or commenter in KNOWN_AGENTS:
continue
if f"@{agent}" in body:
log(f"MENTION @{agent} in {key} comment {c['id']} by {commenter}")
tasks.append({
"key": key, "repo": repo, "issue": num,
"title": issue.get("title", ""),
"by": commenter,
"body": (c.get("body", "") or "")[:500],
})
# Assignment check
if assignee == agent:
hk = hashlib.sha256(f"{key}|{issue.get('updated_at', '')}".encode()).hexdigest()[:16]
if seen.get(f"{key}/assign") != hk:
seen[f"{key}/assign"] = hk
log(f"ASSIGNED {key} to @{agent}")
tasks.append({
"key": key, "repo": repo, "issue": num,
"title": issue.get("title", ""),
})
save_state(agent, state)
if tasks:
log(f"Found {len(tasks)} task(s) for @{agent}")
for t in tasks:
prompt = f"""You are {agent}. Gitea event requires attention.
Issue: {t['repo']} #{t['issue']}{t['title']}
URL: https://forge.alexanderwhitestone.com/{t['repo']}/issues/{t['issue']}
"""
if t.get("by"):
prompt += f"\nComment by @{t['by']}:\n{t.get('body', '')}\n"
prompt += """
Steps:
1. Read the full issue at the URL above
2. Understand what is needed
3. Clone repo, make changes on a branch
4. Commit, push, create PR
5. Comment on the issue
Terse. One issue. Commit early."""
dispatch(prompt, agent)
else:
log(f"No new events for @{agent}")
return len(tasks)
def main():
p = argparse.ArgumentParser(description="VPS Gitea Heartbeat")
p.add_argument("--agent", required=True)
p.add_argument("--once", action="store_true")
p.add_argument("--daemon", action="store_true")
p.add_argument("--interval", type=int, default=POLL_INTERVAL)
args = p.parse_args()
token = load_token()
if not token:
log("No Gitea token", "ERROR"); sys.exit(1)
if args.daemon:
log(f"Daemon for @{args.agent} (interval {args.interval}s)")
while True:
try: poll(args.agent, token)
except Exception as e: log(f"Error: {e}", "ERROR")
time.sleep(args.interval)
else:
poll(args.agent, token)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,66 @@
---
name: vps-gitea-heartbeat
description: "VPS agents (Ezra, Bezalel) not responding to Gitea @mentions. Polls Gitea and dispatches to local hermes chat."
version: 1.0.0
author: Timmy Time
metadata:
hermes:
tags: [gitea, heartbeat, vps, dispatch, ezra, bezalel, rca]
---
# VPS Gitea Heartbeat
## Problem
Tagging @ezra or @bezalel in Gitea issue comments produced no response.
## Root Causes
1. Mac-local dispatch queue has no VPS reader
2. No VPS-native polling exists on Ezra/Bezalel boxes
## Solution
Standalone polling heartbeat on each VPS. Every 5 min:
1. Poll Gitea API for @mentions and assignments
2. Dispatch to local `hermes chat` with issue context
3. Track seen events (dedup)
## Install
```bash
# Ezra VPS (143.198.27.163)
scp scripts/vps-gitea-heartbeat.py root@ezra:~/.hermes/bin/
ssh root@ezra 'bash -s' < scripts/vps-gitea-heartbeat-install.sh ezra cron
# Bezalel VPS (159.203.146.185)
scp scripts/vps-gitea-heartbeat.py root@bezalel:~/.hermes/bin/
ssh root@bezalel 'bash -s' < scripts/vps-gitea-heartbeat-install.sh bezalel cron
```
## Usage
```bash
python3 vps-gitea-heartbeat.py --agent ezra --once
python3 vps-gitea-heartbeat.py --agent bezalel --daemon --interval 300
```
## Env Vars
| Var | Default | Description |
|-----|---------|-------------|
| `GITEA_URL` | `https://forge.alexanderwhitestone.com` | Gitea instance |
| `GITEA_TOKEN_FILE` | `~/.hermes/gitea_token_vps` | Token file |
| `POLL_INTERVAL` | `300` | Seconds between polls |
| `DISPATCH_TIMEOUT` | `600` | Max seconds for hermes chat |
## State
`~/.hermes/gitea-heartbeat-{agent}.json` — seen comment IDs and issue hashes. Each event processed once.
## Pitfalls
- Token must exist on the VPS (`~/.hermes/gitea_token_vps`)
- `hermes` must be on PATH for dispatch
- 5-min latency with crontab; use `--daemon` for faster
- Failed dispatch still marks comment as seen — delete state file to retry