Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy
3e4a8518c0 fix: VPS-native Gitea @mention heartbeat for Ezra/Bezalel (#579)
Some checks failed
Smoke Test / smoke (pull_request) Failing after 16s
RCA: Ezra and Bezalel don't respond to Gitea @mentions because:
1. They're not in AGENT_USERS (mentions silently dropped)
2. Dispatch queue is Mac-local, VPS agents have no reader

Fix: VPS-native heartbeat that polls Gitea notifications every 5
minutes, dispatches locally via hermes chat, posts results back.

## Files
- scripts/gitea-mention-heartbeat.sh — main heartbeat (runs on VPS)
- scripts/setup-vps-heartbeat.sh — deployment helper (runs on Mac)
- docs/GITEA_MENTION_HEARTBEAT.md — documentation
- tests/scripts/test_gitea_heartbeat.py — 13 tests

## Deploy
bash scripts/setup-vps-heartbeat.sh --agent ezra --host root@143.198.27.163
bash scripts/setup-vps-heartbeat.sh --agent bezalel --host root@159.203.146.185

Closes #579.
2026-04-13 20:52:09 -04:00
5 changed files with 513 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# Gitea @mention Heartbeat for VPS Agents
Fixes [timmy-home#579](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-home/issues/579):
Ezra and Bezalel do not respond to Gitea @mention tagging.
## Problem
Two compounding root causes:
1. Ezra/Bezalel not in `AGENT_USERS` — mentions detected but silently dropped
2. Dispatch queue is Mac-local — VPS agents have no process reading it
## Solution
VPS-native heartbeat: each agent polls Gitea for unread notifications every 5 minutes,
dispatches work locally via `hermes chat`, and posts results back as issue comments.
## Quick Deploy
```bash
# On Mac:
cd timmy-home/scripts
# Deploy to Ezra
bash setup-vps-heartbeat.sh --agent ezra --host root@143.198.27.163
# Deploy to Bezalel
bash setup-vps-heartbeat.sh --agent bezalel --host root@159.203.146.185
```
## Manual Setup on VPS
```bash
# 1. Copy script
scp gitea-mention-heartbeat.sh root@VPS:/usr/local/bin/
# 2. Create token file
ssh root@VPS 'mkdir -p ~/.config/gitea'
ssh root@VPS 'echo "YOUR_GITEA_TOKEN" > ~/.config/gitea/ezra-token'
ssh root@VPS 'chmod 600 ~/.config/gitea/ezra-token'
# 3. Add cron
ssh root@VPS '(crontab -l 2>/dev/null; echo "*/5 * * * * /usr/local/bin/gitea-mention-heartbeat.sh --agent ezra --token-file ~/.config/gitea/ezra-token") | crontab -'
# 4. Test
ssh root@VPS '/usr/local/bin/gitea-mention-heartbeat.sh --agent ezra --token-file ~/.config/gitea/ezra-token'
```
## How It Works
```
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Gitea │────►│ Heartbeat (cron 5m) │────►│ hermes chat │
│ @ezra │ │ Poll notifications │ │ (local) │
│ mention │ │ Filter for agent │ │ │
└──────────────┘ │ Dispatch via hermes │ └──────────────┘
│ Post result back │
└──────────────────────┘
```
1. Cron runs every 5 minutes
2. Polls Gitea `/notifications` for unread items
3. Filters for mentions of the agent name
4. Dispatches to local `hermes chat` with issue context
5. Posts the response as a Gitea comment
6. Marks notification as read
## Monitoring
```bash
# Watch logs
ssh root@ezra 'tail -f ~/.hermes/logs/ezra-heartbeat.log'
# Check cron
ssh root@ezra 'crontab -l | grep heartbeat'
# Test manually
ssh root@ezra '/usr/local/bin/gitea-mention-heartbeat.sh --agent ezra --token-file ~/.config/gitea/ezra-token'
```
## Files
- `gitea-mention-heartbeat.sh` — main heartbeat script (runs on VPS)
- `setup-vps-heartbeat.sh` — deployment helper (runs on Mac)

View File

@@ -0,0 +1,236 @@
#!/bin/bash
# gitea-mention-heartbeat.sh — Generic Gitea @mention heartbeat for VPS agents
#
# Polls Gitea for unread notifications mentioning this agent, dispatches
# work locally via hermes, and posts results back as issue comments.
#
# Usage:
# gitea-mention-heartbeat.sh --agent ezra --token-file ~/.config/gitea/ezra-token
# gitea-mention-heartbeat.sh --agent bezalel --token-file ~/.config/gitea/bezalel-token
#
# Install on VPS:
# cp gitea-mention-heartbeat.sh /usr/local/bin/
# chmod +x /usr/local/bin/gitea-mention-heartbeat.sh
# # Add to crontab: */5 * * * * /usr/local/bin/gitea-mention-heartbeat.sh --agent ezra --token-file ~/.config/gitea/ezra-token
#
# Ref: timmy-home#579
set -euo pipefail
# --- Defaults ---
BASE="${GITEA_API_BASE:-https://forge.alexanderwhitestone.com/api/v1}"
LOG_DIR="${HOME}/.hermes/logs"
MAX_DISPATCH=3
DISPATCH_TIMEOUT=600 # 10 minutes
# --- Parse args ---
AGENT=""
TOKEN_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--agent) AGENT="$2"; shift 2 ;;
--token-file) TOKEN_FILE="$2"; shift 2 ;;
--base) BASE="$2"; shift 2 ;;
--log-dir) LOG_DIR="$2"; shift 2 ;;
--timeout) DISPATCH_TIMEOUT="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
if [ -z "$AGENT" ] || [ -z "$TOKEN_FILE" ]; then
echo "Usage: $0 --agent <name> --token-file <path>" >&2
exit 1
fi
if [ ! -f "$TOKEN_FILE" ]; then
echo "Token file not found: $TOKEN_FILE" >&2
exit 1
fi
TOKEN=*** "$TOKEN_FILE" | tr -d '[:space:]')
mkdir -p "$LOG_DIR"
LOG="$LOG_DIR/${AGENT}-heartbeat.log"
LOCKFILE="/tmp/${AGENT}-heartbeat.lock"
PROCESSED_FILE="/tmp/${AGENT}-mentions-processed.txt"
touch "$PROCESSED_FILE"
# --- Helpers ---
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
post_comment() {
local repo="$1" issue_num="$2" body="$3"
local payload
payload=$(python3 -c "import json,sys; print(json.dumps({'body': sys.argv[1]}))" "$body")
curl -sf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" \
"$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true
}
dispatch_local() {
local prompt="$1"
# Try hermes chat first, fall back to direct echo
if command -v hermes &>/dev/null; then
timeout "$DISPATCH_TIMEOUT" hermes chat -p "$prompt" 2>/dev/null || echo "Dispatch timed out after ${DISPATCH_TIMEOUT}s"
else
echo "hermes CLI not available — cannot dispatch locally"
fi
}
# --- Lock ---
if [ -f "$LOCKFILE" ]; then
lock_age=$(( $(date +%s) - $(stat -c %Y "$LOCKFILE" 2>/dev/null || stat -f %m "$LOCKFILE" 2>/dev/null || echo 0) ))
if [ "$lock_age" -lt 300 ]; then
log "SKIP: previous run still active (lock age: ${lock_age}s)"
exit 0
else
log "WARN: stale lock (${lock_age}s), removing"
rm -f "$LOCKFILE"
fi
fi
trap 'rm -f "$LOCKFILE"' EXIT
touch "$LOCKFILE"
# --- Poll notifications ---
log "Polling mentions for @$AGENT..."
notifications=$(curl -sf \
-H "Authorization: token $TOKEN" \
"$BASE/notifications?status-types=unread&limit=20" 2>/dev/null || echo "[]")
if [ "$notifications" = "[]" ] || [ -z "$notifications" ]; then
log "No unread notifications"
exit 0
fi
# Parse notifications
dispatched=0
echo "$notifications" | python3 -c "
import json, sys
try:
notifs = json.loads(sys.stdin.buffer.read())
except:
sys.exit(0)
for n in notifs:
subject = n.get('subject', {})
repo = n.get('repository', {}).get('full_name', '')
title = subject.get('title', '')
url = subject.get('latest_comment_url', '') or subject.get('url', '')
nid = n.get('id', '')
# Only process if the subject title or URL mentions our agent
title_lower = title.lower()
if '${AGENT}' in title_lower or '@${AGENT}' in title_lower:
print(f'{nid}|{repo}|{title}|{url}')
else:
# Check comment body for mentions
print(f'SKIP|{nid}|{repo}|{title}|{url}')
" 2>/dev/null | while IFS='|' read -r flag_or_nid rest; do
if [ "$flag_or_nid" = "SKIP" ]; then
continue
fi
nid="$flag_or_nid"
repo=$(echo "$rest" | cut -d'|' -f1)
title=$(echo "$rest" | cut -d'|' -f2)
url=$(echo "$rest" | cut -d'|' -f3)
[ -z "$nid" ] && continue
# Skip if already processed
grep -q "^${nid}$" "$PROCESSED_FILE" && continue
# Extract issue number
issue_num=$(echo "$url" | grep -o '/issues/[0-9]*' | grep -o '[0-9]*' || echo "")
if [ -z "$issue_num" ]; then
log "SKIP: could not extract issue number from $url"
echo "$nid" >> "$PROCESSED_FILE"
continue
fi
log "FOUND: $repo #$issue_num$title"
# Get comment body
comment_body=""
if [ -n "$url" ]; then
comment_body=$(curl -sf -H "Authorization: token $TOKEN" "$url" 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('body','')[:2000])" 2>/dev/null || echo "")
fi
# Post acknowledgment
post_comment "$repo" "$issue_num" "🔵 **@$AGENT picking up this task** via VPS heartbeat.
Dispatched: $(date -u '+%Y-%m-%dT%H:%M:%SZ')
Agent: $AGENT
Host: $(hostname)"
# Build dispatch prompt
prompt="You are @$AGENT, an AI agent on the Timmy Foundation team.
You were mentioned in Gitea issue #$issue_num in repo $repo.
ISSUE: $title
COMMENT THAT MENTIONED YOU:
$comment_body
YOUR TASK:
1. Read the mention carefully
2. If asked a question, answer it directly
3. If asked to do work, do it (create a PR if code changes are needed)
4. Post your response as a comment on the issue using the Gitea API
5. Be concise and actionable
When done, summarize what you did."
# Dispatch locally
log "DISPATCH: $repo #$issue_num to local hermes (timeout: ${DISPATCH_TIMEOUT}s)"
result=$(dispatch_local "$prompt" 2>&1 || echo "Dispatch failed")
# Post result
if [ -n "$result" ] && [ "$result" != "Dispatch failed" ]; then
escaped=$(echo "$result" | head -50 | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])" 2>/dev/null || echo "See agent logs")
post_comment "$repo" "$issue_num" "🟢 **@$AGENT response:**
$escaped
---
Completed: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
else
post_comment "$repo" "$issue_num" "🔴 **@$AGENT dispatch failed or timed out.**
Timeout: ${DISPATCH_TIMEOUT}s
Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')
The task may need manual attention."
fi
# Mark notification as read
curl -sf -X PATCH \
-H "Authorization: token $TOKEN" \
"$BASE/notifications/threads/$nid" > /dev/null 2>&1 || true
# Mark as processed
echo "$nid" >> "$PROCESSED_FILE"
dispatched=$((dispatched + 1))
log "DISPATCHED: $repo #$issue_num ($dispatched/$MAX_DISPATCH)"
if [ "$dispatched" -ge "$MAX_DISPATCH" ]; then
log "CAPPED: reached $MAX_DISPATCH dispatches"
break
fi
sleep 2
done
if [ "$dispatched" -eq 0 ]; then
log "No new mentions for @$AGENT"
else
log "Completed $dispatched dispatch(es) for @$AGENT"
fi

87
scripts/setup-vps-heartbeat.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/bin/bash
# setup-vps-heartbeat.sh — Deploy Gitea mention heartbeat on a VPS agent box
#
# Usage:
# setup-vps-heartbeat.sh --agent ezra --host root@143.198.27.163
# setup-vps-heartbeat.sh --agent bezalel --host root@159.203.146.185
#
# What it does:
# 1. Copies gitea-mention-heartbeat.sh to the VPS
# 2. Creates a Gitea token file (prompts if missing)
# 3. Installs a cron job (every 5 minutes)
# 4. Tests connectivity
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HEARTBEAT_SCRIPT="$SCRIPT_DIR/gitea-mention-heartbeat.sh"
AGENT=""
HOST=""
while [[ $# -gt 0 ]]; do
case "$1" in
--agent) AGENT="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
if [ -z "$AGENT" ] || [ -z "$HOST" ]; then
echo "Usage: $0 --agent <name> --host <user@host>" >&2
exit 1
fi
echo "=== Deploying Gitea mention heartbeat for @$AGENT on $HOST ==="
# 1. Copy heartbeat script
echo "Copying heartbeat script..."
scp -q "$HEARTBEAT_SCRIPT" "$HOST:/usr/local/bin/gitea-mention-heartbeat.sh"
ssh "$HOST" "chmod +x /usr/local/bin/gitea-mention-heartbeat.sh"
# 2. Ensure token file exists
echo "Checking Gitea token..."
TOKEN_EXISTS=$(ssh "$HOST" "test -f ~/.config/gitea/${AGENT}-token && echo yes || echo no")
if [ "$TOKEN_EXISTS" = "no" ]; then
echo ""
echo "⚠️ No token file found at ~/.config/gitea/${AGENT}-token on $HOST"
echo " Create a Gitea API token for @$AGENT and save it:"
echo " ssh $HOST 'mkdir -p ~/.config/gitea && echo YOUR_TOKEN > ~/.config/gitea/${AGENT}-token && chmod 600 ~/.config/gitea/${AGENT}-token'"
echo ""
echo " Token needs: issue (read/write), notification (read) permissions"
echo ""
fi
# 3. Install cron job
echo "Installing cron job (every 5 minutes)..."
CRON_LINE="*/5 * * * * /usr/local/bin/gitea-mention-heartbeat.sh --agent $AGENT --token-file ~/.config/gitea/${AGENT}-token >> ~/.hermes/logs/${AGENT}-heartbeat-cron.log 2>&1"
# Check if cron already has this line
EXISTING=$(ssh "$HOST" "crontab -l 2>/dev/null | grep -c '${AGENT}-heartbeat' || echo 0")
if [ "$EXISTING" -gt 0 ]; then
echo "Cron job already exists, updating..."
ssh "$HOST" "crontab -l 2>/dev/null | grep -v '${AGENT}-heartbeat' | { cat; echo '$CRON_LINE'; } | crontab -"
else
echo "Adding cron job..."
ssh "$HOST" "(crontab -l 2>/dev/null || true; echo '$CRON_LINE') | crontab -"
fi
# 4. Ensure log directory
ssh "$HOST" "mkdir -p ~/.hermes/logs"
# 5. Test connectivity
echo ""
echo "Testing Gitea API connectivity..."
TEST_RESULT=$(ssh "$HOST" "curl -sf -H \"Authorization: token \$(cat ~/.config/gitea/${AGENT}-token 2>/dev/null || echo 'MISSING')\" 'https://forge.alexanderwhitestone.com/api/v1/user' 2>/dev/null | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('login','ERROR'))\" 2>/dev/null || echo 'FAILED'")
echo " Token belongs to: $TEST_RESULT"
# 6. Dry run
echo ""
echo "Running dry test..."
ssh "$HOST" "/usr/local/bin/gitea-mention-heartbeat.sh --agent $AGENT --token-file ~/.config/gitea/${AGENT}-token" 2>&1 | tail -5
echo ""
echo "=== Deployment complete for @$AGENT on $HOST ==="
echo ""
echo "Logs: ssh $HOST 'tail -f ~/.hermes/logs/${AGENT}-heartbeat.log'"
echo "Cron: ssh $HOST 'crontab -l | grep ${AGENT}'"

View File

View File

@@ -0,0 +1,107 @@
"""Tests for Gitea @mention heartbeat scripts."""
import json
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
@pytest.fixture
def heartbeat_script():
return Path(__file__).parent.parent.parent / "scripts" / "gitea-mention-heartbeat.sh"
@pytest.fixture
def tmp_token(tmp_path):
token_file = tmp_path / "test-token"
token_file.write_text("fake-token-12345")
return token_file
@pytest.fixture
def tmp_log_dir(tmp_path):
log_dir = tmp_path / "logs"
log_dir.mkdir()
return log_dir
class TestArgumentParsing:
def test_requires_agent(self, heartbeat_script):
result = subprocess.run(
["bash", str(heartbeat_script), "--token-file", "/tmp/fake"],
capture_output=True, text=True, timeout=10,
)
assert result.returncode != 0
def test_requires_token_file(self, heartbeat_script):
result = subprocess.run(
["bash", str(heartbeat_script), "--agent", "test"],
capture_output=True, text=True, timeout=10,
)
assert result.returncode != 0
def test_token_file_must_exist(self, heartbeat_script):
result = subprocess.run(
["bash", str(heartbeat_script), "--agent", "test", "--token-file", "/nonexistent/token"],
capture_output=True, text=True, timeout=10,
)
assert result.returncode != 0
class TestScriptStructure:
def test_has_required_functions(self, heartbeat_script):
content = heartbeat_script.read_text()
assert "post_comment" in content
assert "dispatch_local" in content
assert "log()" in content
def test_has_lock_mechanism(self, heartbeat_script):
content = heartbeat_script.read_text()
assert "LOCKFILE" in content
assert "trap" in content
def test_has_notification_polling(self, heartbeat_script):
content = heartbeat_script.read_text()
assert "/notifications" in content
assert "Authorization: token" in content
def test_has_dispatch_cap(self, heartbeat_script):
content = heartbeat_script.read_text()
assert "MAX_DISPATCH" in content
def test_has_processed_tracking(self, heartbeat_script):
content = heartbeat_script.read_text()
assert "PROCESSED_FILE" in content
class TestSetupScript:
def test_setup_exists(self):
setup = Path(__file__).parent.parent.parent / "scripts" / "setup-vps-heartbeat.sh"
assert setup.exists()
assert "setup-vps-heartbeat.sh" in setup.name
def test_setup_has_required_args(self):
setup = Path(__file__).parent.parent.parent / "scripts" / "setup-vps-heartbeat.sh"
content = setup.read_text()
assert "--agent" in content
assert "--host" in content
def test_setup_installs_cron(self):
setup = Path(__file__).parent.parent.parent / "scripts" / "setup-vps-heartbeat.sh"
content = setup.read_text()
assert "crontab" in content
assert "*/5" in content
class TestDocumentation:
def test_docs_exist(self):
docs = Path(__file__).parent.parent.parent / "docs" / "GITEA_MENTION_HEARTBEAT.md"
assert docs.exists()
def test_docs_reference_issue(self):
docs = Path(__file__).parent.parent.parent / "docs" / "GITEA_MENTION_HEARTBEAT.md"
content = docs.read_text()
assert "579" in content