Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
62f0f48006 fix(rca): VPS Gitea heartbeat for Ezra/Bezalel @mentions
Some checks failed
Smoke Test / smoke (pull_request) Failing after 14s
Fixes root cause: Ezra and Bezalel VPS agents don't respond to Gitea
@mentions because no process on those boxes polls Gitea.

Components:
- scripts/vps-gitea-heartbeat.py — Standalone polling script
  - Polls Gitea API for assigned issues and @mentions
  - Deduplicates via persistent state file
  - Dispatches to local hermes chat
  - --once for crontab, --daemon for continuous mode
- scripts/vps-gitea-heartbeat-install.sh — Install helper
  - cron mode: adds */5 crontab entry
  - systemd mode: generates service unit

Deployment:
  scp scripts/vps-gitea-heartbeat.py root@ezra:~/.hermes/bin/
  scp scripts/vps-gitea-heartbeat.py root@bezalel:~/.hermes/bin/

Closes #640 (Closes #579)
2026-04-13 21:24:15 -04:00
2 changed files with 485 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
#!/bin/bash
# ============================================================================
# VPS Gitea Heartbeat Installer
# ============================================================================
#
# Installs the Gitea heartbeat poller as a cron job or systemd service.
#
# Usage:
# bash vps-gitea-heartbeat-install.sh [agent] [cron|systemd]
#
# Examples:
# bash vps-gitea-heartbeat-install.sh ezra cron
# bash vps-gitea-heartbeat-install.sh bezalel systemd
# ============================================================================
set -euo pipefail
AGENT="${1:?Usage: $0 AGENT [cron|systemd]}"
MODE="${2:-cron}"
SCRIPT_PATH="${HOME}/.hermes/bin/vps-gitea-heartbeat.py"
INTERVAL=300 # 5 minutes
GREEN='\033[0;32m'
CYAN='\033[0;36m'
RED='\033[0;31m'
NC='\033[0m'
echo -e "${CYAN}VPS Gitea Heartbeat Installer${NC}"
echo -e " Agent: ${AGENT}"
echo -e " Mode: ${MODE}"
echo ""
# Ensure script exists
if [ ! -f "$SCRIPT_PATH" ]; then
echo -e "${RED}Error: ${SCRIPT_PATH} not found${NC}"
echo "Copy vps-gitea-heartbeat.py to ~/.hermes/bin/ first."
exit 1
fi
# Ensure token exists
TOKEN_FILE="${HOME}/.hermes/gitea_token_vps"
if [ ! -f "$TOKEN_FILE" ]; then
# Try alternate location
TOKEN_FILE="${HOME}/.config/gitea/token"
if [ ! -f "$TOKEN_FILE" ]; then
echo -e "${RED}Error: No Gitea token found${NC}"
echo "Create ~/.hermes/gitea_token_vps with your Gitea API token."
exit 1
fi
fi
echo -e "${GREEN}${NC} Token found"
# Create log directory
mkdir -p "${HOME}/.hermes/logs/gitea-heartbeat"
if [ "$MODE" = "cron" ]; then
# ── Cron installation ────────────────────────────────────────────────
CRON_LINE="*/5 * * * * /usr/bin/python3 ${SCRIPT_PATH} --agent ${AGENT} --once >> ${HOME}/.hermes/logs/gitea-heartbeat/cron-${AGENT}.log 2>&1"
# Check if already installed
if crontab -l 2>/dev/null | grep -q "vps-gitea-heartbeat.*--agent ${AGENT}"; then
echo -e "${CYAN}Cron job already exists for ${AGENT}, updating...${NC}"
# Remove old entry
crontab -l 2>/dev/null | grep -v "vps-gitea-heartbeat.*--agent ${AGENT}" | crontab -
fi
# Add new entry
(crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab -
echo -e "${GREEN}${NC} Cron job installed (every 5 minutes)"
echo ""
echo "Verify with: crontab -l"
elif [ "$MODE" = "systemd" ]; then
# ── Systemd installation ─────────────────────────────────────────────
SERVICE_NAME="gitea-heartbeat-${AGENT}"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
cat > "/tmp/${SERVICE_NAME}.service" << EOF
[Unit]
Description=Gitea Heartbeat for ${AGENT}
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 ${SCRIPT_PATH} --agent ${AGENT} --daemon --interval ${INTERVAL}
Restart=always
RestartSec=30
User=root
Environment=HOME=${HOME}
[Install]
WantedBy=multi-user.target
EOF
echo "Installing systemd service..."
echo -e "${CYAN}Run as root:${NC}"
echo " sudo cp /tmp/${SERVICE_NAME}.service ${SERVICE_FILE}"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable ${SERVICE_NAME}"
echo " sudo systemctl start ${SERVICE_NAME}"
echo ""
echo "Or copy-paste:"
echo " sudo bash -c 'cat > ${SERVICE_FILE}' < /tmp/${SERVICE_NAME}.service && sudo systemctl daemon-reload && sudo systemctl enable --now ${SERVICE_NAME}"
else
echo -e "${RED}Unknown mode: ${MODE}${NC}"
echo "Use 'cron' or 'systemd'"
exit 1
fi
echo ""
echo -e "${GREEN}✓ Installation complete${NC}"
echo ""
echo "Test with:"
echo " python3 ${SCRIPT_PATH} --agent ${AGENT} --once --verbose"

View File

@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""VPS Gitea Heartbeat — Poll Gitea for @mentions and issue assignments.
Runs on Ezra/Bezalel VPS boxes. Every N minutes:
1. Polls Gitea API for issues assigned to this agent
2. Scans recent comments for @mentions of this agent
3. Dispatches actionable events to local `hermes chat`
4. Tracks seen events to avoid duplicates
Usage:
python3 vps-gitea-heartbeat.py --agent ezra --once # Single poll
python3 vps-gitea-heartbeat.py --agent bezalel --daemon # Continuous mode
Ref: #579, #640
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
import urllib.request
import urllib.error
logger = logging.getLogger(__name__)
# Configuration
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
DEFAULT_INTERVAL = 300 # 5 minutes
STATE_DIR = Path.home() / ".hermes"
LOG_DIR = STATE_DIR / "logs" / "gitea-heartbeat"
# Repos to scan for mentions
WATCHED_REPOS = [
"Timmy_Foundation/timmy-home",
"Timmy_Foundation/hermes-agent",
"Timmy_Foundation/timmy-config",
"Timmy_Foundation/the-beacon",
]
def load_gitea_token() -> str:
"""Load Gitea API token from standard locations."""
token_paths = [
Path.home() / ".config" / "gitea" / "token",
Path.home() / ".hermes" / "gitea_token_vps",
Path.home() / ".hermes" / "gitea_token",
]
for path in token_paths:
if path.exists():
return path.read_text().strip()
return os.environ.get("GITEA_TOKEN", "")
def gitea_request(path: str, token: str, method: str = "GET", data: Any = None) -> Any:
"""Make a Gitea API request."""
url = f"{GITEA_BASE}{path}"
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
logger.warning(f"Gitea API error {e.code}: {path}")
return None
except Exception as e:
logger.warning(f"Gitea request failed: {e}")
return None
class HeartbeatState:
"""Persistent state for deduplication."""
def __init__(self, agent: str):
self.agent = agent
self.state_file = STATE_DIR / f"gitea-heartbeat-{agent}.json"
self.seen_comment_ids: Set[str] = set()
self.seen_issue_hashes: Set[str] = set()
self.last_poll: str = ""
self._load()
def _load(self):
if self.state_file.exists():
try:
data = json.loads(self.state_file.read_text())
self.seen_comment_ids = set(data.get("seen_comment_ids", []))
self.seen_issue_hashes = set(data.get("seen_issue_hashes", []))
self.last_poll = data.get("last_poll", "")
except Exception as e:
logger.warning(f"Failed to load state: {e}")
def save(self):
self.state_file.parent.mkdir(parents=True, exist_ok=True)
data = {
"agent": self.agent,
"seen_comment_ids": sorted(self.seen_comment_ids),
"seen_issue_hashes": sorted(self.seen_issue_hashes),
"last_poll": self.last_poll,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
self.state_file.write_text(json.dumps(data, indent=2))
def is_comment_seen(self, comment_id: str) -> bool:
return comment_id in self.seen_comment_ids
def mark_comment_seen(self, comment_id: str):
self.seen_comment_ids.add(comment_id)
# Keep set bounded
if len(self.seen_comment_ids) > 10000:
self.seen_comment_ids = set(sorted(self.seen_comment_ids)[-5000:])
def is_issue_seen(self, issue_key: str) -> bool:
return issue_key in self.seen_issue_hashes
def mark_issue_seen(self, issue_key: str):
self.seen_issue_hashes.add(issue_key)
def poll_assigned_issues(
agent: str, token: str, state: HeartbeatState
) -> List[Dict[str, Any]]:
"""Check for issues assigned to this agent."""
events = []
mention_tag = f"@{agent}"
for repo in WATCHED_REPOS:
# Get open issues
issues = gitea_request(f"/repos/{repo}/issues?state=open&limit=20", token)
if not issues:
continue
for issue in issues:
# Check if assigned to this agent
assignee = issue.get("assignee", {})
if assignee and assignee.get("login", "").lower() == agent.lower():
issue_key = f"{repo}#{issue['number']}"
if not state.is_issue_seen(issue_key):
events.append({
"type": "assignment",
"repo": repo,
"issue_number": issue["number"],
"title": issue.get("title", ""),
"url": issue.get("html_url", ""),
"issue_key": issue_key,
})
state.mark_issue_seen(issue_key)
return events
def poll_mentions(
agent: str, token: str, state: HeartbeatState
) -> List[Dict[str, Any]]:
"""Scan recent comments for @mentions of this agent."""
events = []
mention_tag = f"@{agent}"
for repo in WATCHED_REPOS:
# Get recent issues (last 24h activity)
issues = gitea_request(
f"/repos/{repo}/issues?state=open&limit=10&sort=updated", token
)
if not issues:
continue
for issue in issues:
issue_num = issue["number"]
# Get comments
comments = gitea_request(
f"/repos/{repo}/issues/{issue_num}/comments?limit=10", token
)
if not comments:
continue
for comment in comments:
comment_id = str(comment.get("id", ""))
if state.is_comment_seen(comment_id):
continue
body = comment.get("body", "")
if mention_tag.lower() in body.lower():
events.append({
"type": "mention",
"repo": repo,
"issue_number": issue_num,
"comment_id": comment_id,
"body_preview": body[:200],
"author": comment.get("user", {}).get("login", ""),
"url": comment.get("html_url", ""),
})
state.mark_comment_seen(comment_id)
return events
def dispatch_to_hermes(agent: str, event: Dict[str, Any]) -> bool:
"""Dispatch an event to local hermes chat."""
event_type = event["type"]
repo = event["repo"]
issue_num = event["issue_number"]
title = event.get("title", "")
if event_type == "mention":
prompt = (
f"You were @mentioned on {repo}#{issue_num}. "
f"Check the issue and respond. URL: {event.get('url', '')}"
)
elif event_type == "assignment":
prompt = (
f"You have been assigned {repo}#{issue_num}: {title}. "
f"Implement the fix, commit, push, and open a PR. "
f"URL: {event.get('url', '')}"
)
else:
prompt = f"New event on {repo}#{issue_num}: {event_type}"
try:
# Dispatch via hermes chat CLI
result = subprocess.run(
["hermes", "chat", "--quiet", prompt],
capture_output=True,
text=True,
timeout=600,
env={**os.environ, "HERMES_SESSION_KEY": f"gitea-{agent}-{issue_num}"},
)
if result.returncode == 0:
logger.info(f"Dispatched {event_type} for {repo}#{issue_num} to hermes")
return True
else:
logger.warning(f"hermes chat failed: {result.stderr[:200]}")
return False
except FileNotFoundError:
# hermes not in PATH — try direct path
logger.warning("hermes CLI not found, trying ~/.hermes/bin/")
return False
except subprocess.TimeoutExpired:
logger.warning(f"hermes chat timed out for {repo}#{issue_num}")
return False
except Exception as e:
logger.warning(f"Dispatch failed: {e}")
return False
def log_event(agent: str, event: Dict[str, Any], dispatched: bool):
"""Log event to file."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"{agent}-{datetime.now().strftime('%Y%m%d')}.log"
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"agent": agent,
"dispatched": dispatched,
**event,
}
with open(log_file, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def run_poll(agent: str, token: str) -> int:
"""Run a single poll cycle. Returns number of events dispatched."""
state = HeartbeatState(agent)
state.last_poll = datetime.now(timezone.utc).isoformat()
# Collect events
assignment_events = poll_assigned_issues(agent, token, state)
mention_events = poll_mentions(agent, token, state)
all_events = assignment_events + mention_events
if not all_events:
logger.debug(f"No new events for @{agent}")
state.save()
return 0
logger.info(f"Found {len(all_events)} new event(s) for @{agent}")
# Dispatch
dispatched = 0
for event in all_events:
success = dispatch_to_hermes(agent, event)
log_event(agent, event, success)
if success:
dispatched += 1
state.save()
return dispatched
def run_daemon(agent: str, token: str, interval: int):
"""Run in daemon mode — poll continuously."""
logger.info(f"Starting daemon for @{agent} (interval: {interval}s)")
while True:
try:
run_poll(agent, token)
except Exception as e:
logger.error(f"Poll error: {e}")
time.sleep(interval)
def main():
parser = argparse.ArgumentParser(
description="VPS Gitea Heartbeat — Poll for @mentions and assignments"
)
parser.add_argument(
"--agent", "-a",
required=True,
help="Agent name (ezra, bezalel, etc.)",
)
parser.add_argument(
"--once",
action="store_true",
help="Run single poll and exit",
)
parser.add_argument(
"--daemon", "-d",
action="store_true",
help="Run in daemon mode",
)
parser.add_argument(
"--interval", "-i",
type=int,
default=DEFAULT_INTERVAL,
help=f"Poll interval in seconds (default: {DEFAULT_INTERVAL})",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose logging",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
# Load token
token = load_gitea_token()
if not token:
print("Error: No Gitea token found", file=sys.stderr)
sys.exit(1)
agent = args.agent.lower()
if args.daemon:
run_daemon(agent, token, args.interval)
else:
dispatched = run_poll(agent, token)
print(f"Poll complete: {dispatched} event(s) dispatched")
if __name__ == "__main__":
main()