diff --git a/docs/GITEA_MENTION_HEARTBEAT.md b/docs/GITEA_MENTION_HEARTBEAT.md new file mode 100644 index 0000000..345ebd6 --- /dev/null +++ b/docs/GITEA_MENTION_HEARTBEAT.md @@ -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) diff --git a/scripts/gitea-mention-heartbeat.sh b/scripts/gitea-mention-heartbeat.sh new file mode 100755 index 0000000..6182c16 --- /dev/null +++ b/scripts/gitea-mention-heartbeat.sh @@ -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 --token-file " >&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 diff --git a/scripts/setup-vps-heartbeat.sh b/scripts/setup-vps-heartbeat.sh new file mode 100755 index 0000000..da81bf9 --- /dev/null +++ b/scripts/setup-vps-heartbeat.sh @@ -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 --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}'" diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scripts/test_gitea_heartbeat.py b/tests/scripts/test_gitea_heartbeat.py new file mode 100644 index 0000000..d336756 --- /dev/null +++ b/tests/scripts/test_gitea_heartbeat.py @@ -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