Compare commits
1 Commits
burn/579-1
...
burn/tower
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2efe7b6793 |
1541
evennia/timmy_world/game.py
Normal file
1541
evennia/timmy_world/game.py
Normal file
File diff suppressed because it is too large
Load Diff
275
evennia/timmy_world/play_200.py
Normal file
275
evennia/timmy_world/play_200.py
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Timmy plays The Tower — 200 intentional ticks of real narrative.
|
||||
|
||||
Now with 4 narrative phases:
|
||||
Quietus (1-50): The world is quiet. Characters are still.
|
||||
Fracture (51-100): Something is wrong. The air feels different.
|
||||
Breaking (101-150): The tower shakes. Nothing is safe.
|
||||
Mending (151-200): What was broken can be made whole again.
|
||||
"""
|
||||
from game import GameEngine, NARRATIVE_PHASES
|
||||
import random, json
|
||||
|
||||
random.seed(42) # Reproducible
|
||||
|
||||
engine = GameEngine()
|
||||
engine.start_new_game()
|
||||
|
||||
print("=" * 60)
|
||||
print("THE TOWER — Timmy Plays")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Print phase map
|
||||
print("Narrative Arc:")
|
||||
for key, phase in NARRATIVE_PHASES.items():
|
||||
start, end = phase["ticks"]
|
||||
print(f" [{start:3d}-{end:3d}] {phase['name']:10s} — {phase['subtitle']}")
|
||||
print()
|
||||
|
||||
tick_log = []
|
||||
narrative_highlights = []
|
||||
last_phase = None
|
||||
|
||||
for tick in range(1, 201):
|
||||
w = engine.world
|
||||
room = w.characters["Timmy"]["room"]
|
||||
energy = w.characters["Timmy"]["energy"]
|
||||
here = [n for n, c in w.characters.items()
|
||||
if c["room"] == room and n != "Timmy"]
|
||||
|
||||
# Detect phase transition
|
||||
phase = w.narrative_phase
|
||||
if phase != last_phase:
|
||||
phase_info = NARRATIVE_PHASES[phase]
|
||||
print(f"\n{'='*60}")
|
||||
print(f" PHASE SHIFT: {phase_info['name'].upper()}")
|
||||
print(f" {phase_info['subtitle']}")
|
||||
print(f" Tone: {phase_info['tone']}")
|
||||
print(f"{'='*60}\n")
|
||||
narrative_highlights.append(f" === PHASE: {phase_info['name']} (tick {tick}) ===")
|
||||
last_phase = phase
|
||||
|
||||
# === TIMMY'S DECISIONS (phase-aware) ===
|
||||
|
||||
if energy <= 1:
|
||||
action = "rest"
|
||||
|
||||
# Phase 1: The Watcher (1-20) — Quietus exploration
|
||||
elif tick <= 20:
|
||||
if tick <= 3:
|
||||
action = "look"
|
||||
elif tick <= 6:
|
||||
if room == "Threshold":
|
||||
action = random.choice(["look", "rest"])
|
||||
else:
|
||||
action = "rest"
|
||||
elif tick <= 10:
|
||||
if room == "Threshold" and "Marcus" in here:
|
||||
action = random.choice(["speak:Marcus", "look"])
|
||||
elif room == "Threshold" and "Kimi" in here:
|
||||
action = "speak:Kimi"
|
||||
elif room != "Threshold":
|
||||
if room == "Garden":
|
||||
action = "move:west"
|
||||
else:
|
||||
action = "rest"
|
||||
else:
|
||||
action = "look"
|
||||
elif tick <= 15:
|
||||
if room != "Garden":
|
||||
if room == "Threshold":
|
||||
action = "move:east"
|
||||
elif room == "Bridge":
|
||||
action = "move:north"
|
||||
elif room == "Forge":
|
||||
action = "move:east"
|
||||
elif room == "Tower":
|
||||
action = "move:south"
|
||||
else:
|
||||
action = "rest"
|
||||
else:
|
||||
if "Marcus" in here:
|
||||
action = random.choice(["speak:Marcus", "speak:Kimi", "look", "rest"])
|
||||
else:
|
||||
action = random.choice(["look", "rest"])
|
||||
else:
|
||||
if room == "Garden":
|
||||
action = random.choice(["rest", "look", "look"])
|
||||
else:
|
||||
action = "move:east"
|
||||
|
||||
# Phase 2: The Forge (21-50) — Quietus building
|
||||
elif tick <= 50:
|
||||
if room != "Forge":
|
||||
if room == "Threshold":
|
||||
action = "move:west"
|
||||
elif room == "Bridge":
|
||||
action = "move:north"
|
||||
elif room == "Garden":
|
||||
action = "move:west"
|
||||
elif room == "Tower":
|
||||
action = "move:south"
|
||||
else:
|
||||
action = "rest"
|
||||
else:
|
||||
if energy >= 3:
|
||||
action = random.choice(["tend_fire", "speak:Bezalel", "forge"])
|
||||
else:
|
||||
action = random.choice(["rest", "tend_fire"])
|
||||
|
||||
# Phase 3: The Bridge (51-80) — Fracture begins
|
||||
elif tick <= 80:
|
||||
if room != "Bridge":
|
||||
if room == "Threshold":
|
||||
action = "move:south"
|
||||
elif room == "Forge":
|
||||
action = "move:east"
|
||||
elif room == "Garden":
|
||||
action = "move:west"
|
||||
elif room == "Tower":
|
||||
action = "move:south"
|
||||
else:
|
||||
action = "rest"
|
||||
else:
|
||||
if energy >= 2:
|
||||
action = random.choice(["carve", "examine", "look"])
|
||||
else:
|
||||
action = "rest"
|
||||
|
||||
# Phase 4: The Tower (81-100) — Fracture deepens
|
||||
elif tick <= 100:
|
||||
if room != "Tower":
|
||||
if room == "Threshold":
|
||||
action = "move:north"
|
||||
elif room == "Bridge":
|
||||
action = "move:north"
|
||||
elif room == "Forge":
|
||||
action = "move:east"
|
||||
elif room == "Garden":
|
||||
action = "move:west"
|
||||
else:
|
||||
action = "rest"
|
||||
else:
|
||||
if energy >= 2:
|
||||
action = random.choice(["write_rule", "study", "speak:Ezra"])
|
||||
else:
|
||||
action = random.choice(["rest", "look"])
|
||||
|
||||
# Phase 5: Breaking (101-130) — Crisis
|
||||
elif tick <= 130:
|
||||
# Timmy rushes between rooms trying to help
|
||||
if energy <= 2:
|
||||
action = "rest"
|
||||
elif tick % 7 == 0:
|
||||
action = "tend_fire" if room == "Forge" else "move:west"
|
||||
elif tick % 5 == 0:
|
||||
action = "plant" if room == "Garden" else "move:east"
|
||||
elif "Marcus" in here:
|
||||
action = "speak:Marcus"
|
||||
elif "Bezalel" in here:
|
||||
action = "speak:Bezalel"
|
||||
else:
|
||||
action = random.choice(["move:north", "move:south", "move:east", "move:west"])
|
||||
|
||||
# Phase 6: Breaking peak (131-150) — Desperate
|
||||
elif tick <= 150:
|
||||
if energy <= 1:
|
||||
action = "rest"
|
||||
elif room == "Forge" and w.rooms["Forge"]["fire"] != "glowing":
|
||||
action = "tend_fire"
|
||||
elif room == "Garden":
|
||||
action = random.choice(["plant", "speak:Kimi", "rest"])
|
||||
elif "Marcus" in here:
|
||||
action = random.choice(["speak:Marcus", "help:Marcus"])
|
||||
else:
|
||||
action = "look"
|
||||
|
||||
# Phase 7: Mending begins (151-175)
|
||||
elif tick <= 175:
|
||||
if room != "Garden":
|
||||
if room == "Threshold":
|
||||
action = "move:east"
|
||||
elif room == "Bridge":
|
||||
action = "move:north"
|
||||
elif room == "Forge":
|
||||
action = "move:east"
|
||||
elif room == "Tower":
|
||||
action = "move:south"
|
||||
else:
|
||||
action = "rest"
|
||||
else:
|
||||
action = random.choice(["plant", "speak:Marcus", "speak:Kimi", "rest"])
|
||||
|
||||
# Phase 8: Mending complete (176-200)
|
||||
else:
|
||||
if energy <= 1:
|
||||
action = "rest"
|
||||
elif random.random() < 0.3:
|
||||
action = "move:" + random.choice(["north", "south", "east", "west"])
|
||||
elif "Marcus" in here:
|
||||
action = "speak:Marcus"
|
||||
elif "Bezalel" in here:
|
||||
action = random.choice(["speak:Bezalel", "tend_fire"])
|
||||
elif random.random() < 0.4:
|
||||
action = random.choice(["carve", "write_rule", "forge", "plant"])
|
||||
else:
|
||||
action = random.choice(["look", "rest"])
|
||||
|
||||
# Run the tick
|
||||
result = engine.play_turn(action)
|
||||
|
||||
# Capture narrative highlights
|
||||
highlights = []
|
||||
for line in result['log']:
|
||||
if any(x in line for x in ['says', 'looks', 'carve', 'tend', 'write', 'You rest', 'You move to The']):
|
||||
highlights.append(f" T{tick}: {line}")
|
||||
|
||||
for evt in result.get('world_events', []):
|
||||
if any(x in evt for x in ['rain', 'glows', 'cold', 'dim', 'bloom', 'seed', 'flickers', 'bright', 'PHASE', 'air changes', 'tower groans', 'Silence']):
|
||||
highlights.append(f" [World] {evt}")
|
||||
|
||||
if highlights:
|
||||
tick_log.extend(highlights)
|
||||
|
||||
# Print every 20 ticks
|
||||
if tick % 20 == 0:
|
||||
phase_name = result.get('phase_name', 'unknown')
|
||||
print(f"--- Tick {tick} ({w.time_of_day}) [{phase_name}] ---")
|
||||
for h in highlights[-5:]:
|
||||
print(h)
|
||||
print()
|
||||
|
||||
# Print full narrative
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TIMMY'S JOURNEY — 200 Ticks")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f"Final tick: {w.tick}")
|
||||
print(f"Final time: {w.time_of_day}")
|
||||
print(f"Final phase: {w.narrative_phase} ({NARRATIVE_PHASES[w.narrative_phase]['name']})")
|
||||
print(f"Timmy room: {w.characters['Timmy']['room']}")
|
||||
print(f"Timmy energy: {w.characters['Timmy']['energy']}")
|
||||
print(f"Timmy spoken: {len(w.characters['Timmy']['spoken'])} lines")
|
||||
print(f"Timmy trust: {json.dumps(w.characters['Timmy']['trust'], indent=2)}")
|
||||
print(f"\nWorld state:")
|
||||
print(f" Forge fire: {w.rooms['Forge']['fire']}")
|
||||
print(f" Garden growth: {w.rooms['Garden']['growth']}")
|
||||
print(f" Bridge carvings: {len(w.rooms['Bridge']['carvings'])}")
|
||||
print(f" Whiteboard rules: {len(w.rooms['Tower']['messages'])}")
|
||||
|
||||
print(f"\n=== BRIDGE CARVINGS ===")
|
||||
for c in w.rooms['Bridge']['carvings']:
|
||||
print(f" - {c}")
|
||||
|
||||
print(f"\n=== WHITEBOARD RULES ===")
|
||||
for m in w.rooms['Tower']['messages']:
|
||||
print(f" - {m}")
|
||||
|
||||
print(f"\n=== KEY MOMENTS ===")
|
||||
for h in tick_log:
|
||||
print(h)
|
||||
|
||||
# Save state
|
||||
engine.world.save()
|
||||
@@ -1,51 +0,0 @@
|
||||
# RCA-579: Ezra and Bezalel do not respond to Gitea @mention
|
||||
|
||||
**Issue:** timmy-home#579
|
||||
**Date:** 2026-04-07
|
||||
**Filed by:** Timmy
|
||||
|
||||
## What Broke
|
||||
|
||||
Tagging @ezra or @bezalel in a Gitea issue comment produces no response. The agents do not pick up the work or acknowledge the mention.
|
||||
|
||||
## Root Causes (two compounding)
|
||||
|
||||
### RC-1: Ezra and Bezalel were not in AGENT_USERS
|
||||
|
||||
`~/.hermes/bin/gitea-event-watcher.py` had two sets:
|
||||
|
||||
- `KNOWN_AGENTS` — used to *detect* mentions (ezra/bezalel were present)
|
||||
- `AGENT_USERS` — used to *dispatch* work (ezra/bezalel were missing)
|
||||
|
||||
When they were tagged, the watcher saw the mention but had no dispatch handler — the event was silently dropped.
|
||||
|
||||
**Status:** FIXED (2026-04-08) — ezra/bezalel added to AGENT_USERS with `"vps": True` markers.
|
||||
|
||||
### RC-2: Dispatch queue is Mac-local, VPS agents have no reader
|
||||
|
||||
Even after RC-1 was fixed, the dispatch queue (`~/.hermes/burn-logs/dispatch-queue.json`) lives on the Mac. The agent loops that consume this queue (claude-loop.sh, gemini-loop.sh) also run on the Mac. Ezra and Bezalel run `hermes gateway` on separate VPS boxes with no process polling the Mac-local queue.
|
||||
|
||||
## Fix
|
||||
|
||||
### 1. VPS-native heartbeat (scripts/vps-agent-heartbeat.sh)
|
||||
|
||||
New script that runs directly on each VPS agent's box. Polls Gitea for issues/comments mentioning the agent, dispatches locally via `hermes chat`. Follows the proven kimi-heartbeat.sh pattern.
|
||||
|
||||
- No SSH tunnel required
|
||||
- No Mac dependency
|
||||
- Polls every 5 minutes via crontab
|
||||
- Tracks processed items to avoid duplicates
|
||||
|
||||
### 2. Mac-side VPS dispatch worker (scripts/vps-dispatch-worker.py)
|
||||
|
||||
Complementary Mac-side worker that reads the dispatch queue and SSHes work to VPS agents. Lower latency for active sessions when the Mac watcher detects mentions before the VPS heartbeat polls.
|
||||
|
||||
### 3. Deployment script (scripts/deploy-vps-heartbeat.sh)
|
||||
|
||||
One-command deployment to Ezra and Bezalel VPS boxes. Copies the heartbeat, configures .env, sets up crontab.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Tag @ezra on a test issue → response within 15 minutes
|
||||
2. Tag @bezalel on a test issue → response within 15 minutes
|
||||
3. Check VPS logs: `ssh root@143.198.27.163 'tail -5 /tmp/vps-heartbeat-ezra.log'`
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/bin/bash
|
||||
# deploy-vps-heartbeat.sh — Deploy the VPS agent heartbeat to Ezra and Bezalel VPS boxes.
|
||||
#
|
||||
# Usage: bash scripts/deploy-vps-heartbeat.sh [ezra|bezalel|all]
|
||||
#
|
||||
# Prerequisites:
|
||||
# - SSH access to VPS boxes (key-based)
|
||||
# - Gitea tokens on the VPS (passed via env or copied)
|
||||
# - hermes installed on the VPS
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HEARTBEAT_SCRIPT="${SCRIPT_DIR}/vps-agent-heartbeat.sh"
|
||||
|
||||
# VPS configurations
|
||||
declare -A VPS_HOSTS=(
|
||||
["ezra"]="root@143.198.27.163"
|
||||
["bezalel"]="root@159.203.146.185"
|
||||
)
|
||||
|
||||
# Gitea tokens (read from local config)
|
||||
EZRA_TOKEN_FILE="$HOME/.config/gitea/ezra-token"
|
||||
BEZALEL_TOKEN_FILE="$HOME/.config/gitea/bezalel-token"
|
||||
TIMMY_TOKEN_FILE="$HOME/.config/gitea/timmy-token"
|
||||
|
||||
TARGET="${1:-all}"
|
||||
|
||||
deploy_agent() {
|
||||
local agent="$1"
|
||||
local host="${VPS_HOSTS[$agent]}"
|
||||
|
||||
echo "=== Deploying heartbeat to ${agent} (${host}) ==="
|
||||
|
||||
# Determine token file
|
||||
local token_file=""
|
||||
case "$agent" in
|
||||
ezra) token_file="$EZRA_TOKEN_FILE" ;;
|
||||
bezalel) token_file="$BEZALEL_TOKEN_FILE" ;;
|
||||
esac
|
||||
|
||||
# Fall back to timmy token if agent-specific token doesn't exist
|
||||
if [ ! -f "$token_file" ]; then
|
||||
echo "WARN: ${agent}-specific token not found, using timmy token"
|
||||
token_file="$TIMMY_TOKEN_FILE"
|
||||
fi
|
||||
|
||||
if [ ! -f "$token_file" ]; then
|
||||
echo "ERROR: No Gitea token found for ${agent}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local token
|
||||
token=$(cat "$token_file" | tr -d '[:space:]')
|
||||
|
||||
# Copy heartbeat script
|
||||
scp "$HEARTBEAT_SCRIPT" "${host}:/opt/timmy/vps-agent-heartbeat.sh"
|
||||
|
||||
# Create .env file on VPS
|
||||
ssh "$host" "mkdir -p /opt/timmy && cat > /opt/timmy/vps-agent-heartbeat.env" <<EOF
|
||||
AGENT_NAME=${agent}
|
||||
GITEA_TOKEN=${token}
|
||||
GITEA_BASE=https://forge.alexanderwhitestone.com/api/v1
|
||||
HERMES_BIN=hermes
|
||||
HERMES_PROFILE=${agent}
|
||||
MAX_DISPATCH=5
|
||||
EOF
|
||||
|
||||
# Make script executable
|
||||
ssh "$host" "chmod +x /opt/timmy/vps-agent-heartbeat.sh"
|
||||
|
||||
# Set up crontab (every 5 minutes, if not already present)
|
||||
ssh "$host" "
|
||||
crontab -l 2>/dev/null | grep -v 'vps-agent-heartbeat' > /tmp/crontab.tmp || true
|
||||
echo '*/5 * * * * cd /opt/timmy && source vps-agent-heartbeat.env && bash vps-agent-heartbeat.sh >> /tmp/vps-heartbeat-${agent}.log 2>&1' >> /tmp/crontab.tmp
|
||||
crontab /tmp/crontab.tmp
|
||||
rm /tmp/crontab.tmp
|
||||
"
|
||||
|
||||
echo " ✓ Script deployed to /opt/timmy/vps-agent-heartbeat.sh"
|
||||
echo " ✓ Env configured at /opt/timmy/vps-agent-heartbeat.env"
|
||||
echo " ✓ Crontab set: every 5 minutes"
|
||||
echo ""
|
||||
}
|
||||
|
||||
if [ "$TARGET" = "all" ]; then
|
||||
for agent in ezra bezalel; do
|
||||
deploy_agent "$agent"
|
||||
done
|
||||
elif [ -n "${VPS_HOSTS[$TARGET]+x}" ]; then
|
||||
deploy_agent "$TARGET"
|
||||
else
|
||||
echo "Usage: $0 [ezra|bezalel|all]"
|
||||
echo "Available: ${!VPS_HOSTS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Deployment complete ==="
|
||||
echo ""
|
||||
echo "Verify with:"
|
||||
echo " ssh ${VPS_HOSTS[ezra]} 'cat /tmp/vps-heartbeat-ezra.log | tail -5'"
|
||||
echo " ssh ${VPS_HOSTS[bezalel]} 'cat /tmp/vps-heartbeat-bezalel.log | tail -5'"
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/bin/bash
|
||||
# vps-agent-heartbeat.sh — VPS-native Gitea mention/assignment watcher for VPS agents.
|
||||
#
|
||||
# Polls Gitea for issues/comments mentioning a specific agent (Ezra, Bezalel, etc.),
|
||||
# dispatches locally via hermes chat. Follows the kimi-heartbeat.sh pattern.
|
||||
#
|
||||
# This solves timmy-home#579: Ezra/Bezalel were detected by the Mac watcher but
|
||||
# had no VPS-side consumer. This script runs directly on each VPS, polling Gitea
|
||||
# and dispatching hermes locally — no SSH tunnel, no Mac dependency.
|
||||
#
|
||||
# Setup on VPS:
|
||||
# 1. Copy this script and the .env file to the VPS
|
||||
# 2. Source .env or set AGENT_NAME, GITEA_TOKEN, GITEA_BASE
|
||||
# 3. Add to crontab: */5 * * * * /path/to/vps-agent-heartbeat.sh
|
||||
#
|
||||
# Config via env vars (or .env file alongside this script):
|
||||
# AGENT_NAME — lowercase agent name (ezra, bezalel)
|
||||
# GITEA_TOKEN — Gitea API token with repo access
|
||||
# GITEA_BASE — Gitea base URL (default: https://forge.alexanderwhitestone.com/api/v1)
|
||||
# HERMES_BIN — path to hermes binary (default: hermes)
|
||||
# HERMES_PROFILE — hermes profile to use (default: same as AGENT_NAME)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Config from env ---
|
||||
AGENT_NAME="${AGENT_NAME:?AGENT_NAME is required}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}"
|
||||
GITEA_BASE="${GITEA_BASE:-https://forge.alexanderwhitestone.com/api/v1}"
|
||||
HERMES_BIN="${HERMES_BIN:-hermes}"
|
||||
HERMES_PROFILE="${HERMES_PROFILE:-$AGENT_NAME}"
|
||||
|
||||
# --- Paths ---
|
||||
LOG="/tmp/vps-heartbeat-${AGENT_NAME}.log"
|
||||
LOCKFILE="/tmp/vps-heartbeat-${AGENT_NAME}.lock"
|
||||
PROCESSED="/tmp/vps-heartbeat-${AGENT_NAME}-processed.txt"
|
||||
MAX_DISPATCH="${MAX_DISPATCH:-5}"
|
||||
|
||||
touch "$PROCESSED"
|
||||
|
||||
# --- Repos to watch ---
|
||||
REPOS=(
|
||||
"Timmy_Foundation/timmy-home"
|
||||
"Timmy_Foundation/timmy-config"
|
||||
"Timmy_Foundation/the-nexus"
|
||||
"Timmy_Foundation/hermes-agent"
|
||||
"Timmy_Foundation/the-beacon"
|
||||
)
|
||||
|
||||
# --- Helpers ---
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$AGENT_NAME] $*" | tee -a "$LOG"; }
|
||||
|
||||
gitea_api() {
|
||||
curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_BASE}$1" 2>/dev/null
|
||||
}
|
||||
|
||||
is_processed() { grep -qF "$1" "$PROCESSED" 2>/dev/null; }
|
||||
mark_processed() { echo "$1" >> "$PROCESSED"; }
|
||||
|
||||
# Prevent overlapping runs
|
||||
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"
|
||||
|
||||
# --- Main ---
|
||||
dispatched=0
|
||||
log "Heartbeat starting. Watching ${#REPOS[@]} repos."
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
[ "$dispatched" -ge "$MAX_DISPATCH" ] && break
|
||||
|
||||
IFS='/' read -r owner repo_name <<< "$repo"
|
||||
|
||||
# Fetch recent open issues
|
||||
issues=$(gitea_api "/repos/${owner}/${repo_name}/issues?state=open&limit=30&sort=recentupdate") || continue
|
||||
[ -z "$issues" ] || [ "$issues" = "null" ] && continue
|
||||
|
||||
echo "$issues" | python3 -c "
|
||||
import json, sys
|
||||
issues = json.load(sys.stdin)
|
||||
for i in issues:
|
||||
if i.get('pull_request'):
|
||||
continue
|
||||
assignee = (i.get('assignee') or {}).get('login', '').lower()
|
||||
title = i.get('title', '')
|
||||
num = i.get('number', 0)
|
||||
updated = i.get('updated_at', '')
|
||||
print(f'{num}|{assignee}|{title}|{updated}')
|
||||
" 2>/dev/null | while IFS='|' read -r issue_num assignee title updated; do
|
||||
[ "$dispatched" -ge "$MAX_DISPATCH" ] && break
|
||||
[ -z "$issue_num" ] && continue
|
||||
|
||||
# Check if this issue mentions or is assigned to us
|
||||
mention_key="${repo}#${issue_num}"
|
||||
is_assigned=false
|
||||
is_mentioned=false
|
||||
|
||||
if [ "$assignee" = "$AGENT_NAME" ]; then
|
||||
is_assigned=true
|
||||
fi
|
||||
|
||||
# Check comments for @mention
|
||||
comments=$(gitea_api "/repos/${owner}/${repo_name}/issues/${issue_num}/comments?limit=10&sort=created") || continue
|
||||
mention_found=$(echo "$comments" | python3 -c "
|
||||
import json, sys
|
||||
agent = '${AGENT_NAME}'
|
||||
comments = json.load(sys.stdin)
|
||||
for c in comments:
|
||||
body = (c.get('body', '') or '').lower()
|
||||
commenter = (c.get('user') or {}).get('login', '').lower()
|
||||
cid = c.get('id', 0)
|
||||
if f'@{agent}' in body and commenter != agent:
|
||||
print(f'{cid}')
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$mention_found" ]; then
|
||||
mention_key="${mention_key}/comment-${mention_found}"
|
||||
is_mentioned=true
|
||||
fi
|
||||
|
||||
# Skip if already processed
|
||||
if is_processed "$mention_key"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if neither assigned nor mentioned
|
||||
if [ "$is_assigned" = false ] && [ "$is_mentioned" = false ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Build context for hermes
|
||||
log "FOUND: ${repo}#${issue_num} — ${title} (assigned=$is_assigned, mentioned=$is_mentioned)"
|
||||
|
||||
# Fetch issue body
|
||||
issue_detail=$(gitea_api "/repos/${owner}/${repo_name}/issues/${issue_num}") || continue
|
||||
issue_body=$(echo "$issue_detail" | python3 -c "import json,sys; print(json.load(sys.stdin).get('body','')[:2000])" 2>/dev/null || echo "")
|
||||
|
||||
# Fetch recent comment context
|
||||
comment_context=$(echo "$comments" | python3 -c "
|
||||
import json, sys
|
||||
agent = '${AGENT_NAME}'
|
||||
comments = json.load(sys.stdin)
|
||||
for c in reversed(comments):
|
||||
body = c.get('body', '') or ''
|
||||
commenter = (c.get('user') or {}).get('login', 'unknown')
|
||||
if f'@{agent}' in body.lower():
|
||||
print(f'--- Comment by @{commenter} ---')
|
||||
print(body[:1000])
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
# Build the hermes prompt
|
||||
prompt="You are ${AGENT_NAME^} on the Timmy Foundation. A Gitea issue needs your attention.
|
||||
|
||||
REPO: ${repo}
|
||||
ISSUE: #${issue_num} — ${title}
|
||||
|
||||
ISSUE BODY:
|
||||
${issue_body}
|
||||
|
||||
MENTION CONTEXT:
|
||||
${comment_context:-No specific mention context.}
|
||||
|
||||
YOUR TASK:
|
||||
Respond to this issue. If someone mentioned you, acknowledge the mention and address what they asked.
|
||||
If the issue is assigned to you, work on it — read the body, implement what's needed, and push changes.
|
||||
Post your response as a comment on the issue via Gitea API.
|
||||
Gitea: ${GITEA_BASE%/}/api/v1, token from environment."
|
||||
|
||||
# Dispatch via hermes chat
|
||||
log "DISPATCHING: hermes chat (profile=$HERMES_PROFILE) for ${repo}#${issue_num}"
|
||||
if command -v "$HERMES_BIN" &>/dev/null; then
|
||||
echo "$prompt" | timeout 600 "$HERMES_BIN" chat --profile "$HERMES_PROFILE" --stdin > "/tmp/vps-dispatch-${AGENT_NAME}-${issue_num}.log" 2>&1 &
|
||||
dispatched=$((dispatched + 1))
|
||||
mark_processed "$mention_key"
|
||||
log "DISPATCHED: ${repo}#${issue_num} (${dispatched}/${MAX_DISPATCH})"
|
||||
else
|
||||
log "ERROR: hermes binary not found at $HERMES_BIN"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
log "Heartbeat complete. Dispatched: ${dispatched}"
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
vps-dispatch-worker.py — Mac-side worker that dispatches queued work to VPS agents.
|
||||
|
||||
Reads the dispatch queue (~/.hermes/burn-logs/dispatch-queue.json) and for agents
|
||||
marked with "vps": True in AGENT_USERS, SSHes into their VPS box and runs
|
||||
hermes chat with the task context.
|
||||
|
||||
This complements the VPS-native heartbeat (vps-agent-heartbeat.sh) for cases
|
||||
where the Mac gitea-event-watcher.py detects mentions before the VPS heartbeat
|
||||
polls. Both paths work; this one is lower-latency for active work sessions.
|
||||
|
||||
Usage:
|
||||
python3 scripts/vps-dispatch-worker.py
|
||||
# or with specific agent filter:
|
||||
python3 scripts/vps-dispatch-worker.py --agent ezra
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DISPATCH_QUEUE = Path("~/.hermes/burn-logs/dispatch-queue.json").expanduser()
|
||||
LOG_FILE = Path("~/.hermes/burn-logs/vps-dispatch.log").expanduser()
|
||||
|
||||
# VPS agent configs: agent name → SSH host
|
||||
VPS_AGENTS = {
|
||||
"ezra": {
|
||||
"host": "root@143.198.27.163",
|
||||
"hermes_profile": "ezra",
|
||||
"token_file": Path("~/.config/gitea/ezra-token").expanduser(),
|
||||
},
|
||||
"bezalel": {
|
||||
"host": "root@159.203.146.185",
|
||||
"hermes_profile": "bezalel",
|
||||
"token_file": Path("~/.config/gitea/bezalel-token").expanduser(),
|
||||
},
|
||||
}
|
||||
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
|
||||
|
||||
def log(msg):
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{ts}] {msg}\n"
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
print(line.strip())
|
||||
|
||||
|
||||
def load_queue():
|
||||
if DISPATCH_QUEUE.exists():
|
||||
with open(DISPATCH_QUEUE, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def save_queue(queue):
|
||||
DISPATCH_QUEUE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(DISPATCH_QUEUE, "w", encoding="utf-8") as f:
|
||||
json.dump(queue, f, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def build_prompt(agent_name, item):
|
||||
"""Build the hermes chat prompt from a dispatch queue item."""
|
||||
work_type = item.get("type", "unknown")
|
||||
full_name = item.get("full_name", "unknown/repo")
|
||||
issue_num = item.get("issue", item.get("pr", "?"))
|
||||
title = item.get("title", "")
|
||||
|
||||
comments = item.get("comments", [])
|
||||
comment_text = ""
|
||||
if comments:
|
||||
for c in comments[-3:]: # last 3 comments
|
||||
user = c.get("user", "unknown")
|
||||
body = c.get("body_preview", "")
|
||||
comment_text += f"\n@{user}: {body[:300]}"
|
||||
|
||||
return (
|
||||
f"You are {agent_name.title()} on the Timmy Foundation. "
|
||||
f"A Gitea event needs your attention.\n\n"
|
||||
f"REPO: {full_name}\n"
|
||||
f"ISSUE: #{issue_num} — {title}\n"
|
||||
f"EVENT: {work_type}\n"
|
||||
f"RECENT COMMENTS:{comment_text or ' (none)'}\n\n"
|
||||
f"YOUR TASK:\n"
|
||||
f"Address this issue. If someone mentioned you, respond to them.\n"
|
||||
f"If assigned, work on the issue — read the body, implement, push changes.\n"
|
||||
f"Post your response as a comment on the issue via Gitea API.\n"
|
||||
f"Gitea: {GITEA_BASE}, token from environment."
|
||||
)
|
||||
|
||||
|
||||
def dispatch_to_vps(agent_name, config, item):
|
||||
"""SSH into the VPS and run hermes chat."""
|
||||
prompt = build_prompt(agent_name, item)
|
||||
host = config["host"]
|
||||
profile = config["hermes_profile"]
|
||||
work_id = item.get("work_id", "unknown")
|
||||
|
||||
# Build the SSH command to run hermes chat on the VPS
|
||||
# We pipe the prompt via stdin to avoid shell escaping issues
|
||||
ssh_cmd = [
|
||||
"ssh", "-o", "ConnectTimeout=10",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
host,
|
||||
f"echo {shell_quote(prompt)} | hermes chat --profile {profile} --stdin --timeout 300"
|
||||
]
|
||||
|
||||
log(f"DISPATCH {agent_name}: SSH to {host} for {work_id}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
ssh_cmd,
|
||||
capture_output=True, text=True,
|
||||
timeout=360, # 6 min total timeout
|
||||
)
|
||||
if result.returncode == 0:
|
||||
log(f"OK {agent_name}: {work_id} completed")
|
||||
return True
|
||||
else:
|
||||
log(f"FAIL {agent_name}: {work_id} exit={result.returncode} stderr={result.stderr[:200]}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
log(f"TIMEOUT {agent_name}: {work_id} after 360s")
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"ERROR {agent_name}: {work_id} — {e}")
|
||||
return False
|
||||
|
||||
|
||||
def shell_quote(s):
|
||||
"""Quote a string for safe shell interpolation."""
|
||||
import shlex
|
||||
return shlex.quote(s)
|
||||
|
||||
|
||||
def main():
|
||||
agent_filter = None
|
||||
if "--agent" in sys.argv:
|
||||
idx = sys.argv.index("--agent")
|
||||
if idx + 1 < len(sys.argv):
|
||||
agent_filter = sys.argv[idx + 1]
|
||||
|
||||
queue = load_queue()
|
||||
dispatched = 0
|
||||
failed = 0
|
||||
|
||||
for agent_name, config in VPS_AGENTS.items():
|
||||
if agent_filter and agent_name != agent_filter:
|
||||
continue
|
||||
|
||||
items = queue.get(agent_name, [])
|
||||
if not items:
|
||||
continue
|
||||
|
||||
log(f"Processing {len(items)} items for {agent_name}")
|
||||
|
||||
# Process items (pop from queue as we go)
|
||||
remaining = []
|
||||
for item in items[:5]: # Max 5 per run
|
||||
success = dispatch_to_vps(agent_name, config, item)
|
||||
if success:
|
||||
dispatched += 1
|
||||
else:
|
||||
failed += 1
|
||||
remaining.append(item) # Keep failed items for retry
|
||||
|
||||
# Update queue: keep unprocessed items
|
||||
if remaining:
|
||||
queue[agent_name] = remaining
|
||||
elif agent_name in queue:
|
||||
del queue[agent_name]
|
||||
|
||||
save_queue(queue)
|
||||
|
||||
if dispatched or failed:
|
||||
log(f"Done: {dispatched} dispatched, {failed} failed")
|
||||
else:
|
||||
print("No VPS agent work items in queue.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user