Compare commits
1 Commits
fix/682
...
q/579-1776
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2ad89e40 |
36
scripts/vps-gitea-heartbeat-install.sh
Normal file
36
scripts/vps-gitea-heartbeat-install.sh
Normal 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"
|
||||
212
scripts/vps-gitea-heartbeat.py
Normal file
212
scripts/vps-gitea-heartbeat.py
Normal 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()
|
||||
66
skills/autonomous-ai-agents/vps-gitea-heartbeat/SKILL.md
Normal file
66
skills/autonomous-ai-agents/vps-gitea-heartbeat/SKILL.md
Normal 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
|
||||
Reference in New Issue
Block a user