#!/usr/bin/env bash # agent-loop.sh — Universal agent dev loop with Genchi Genbutsu verification # # Usage: agent-loop.sh [num-workers] # agent-loop.sh claude 2 # agent-loop.sh gemini 1 # # Dispatches via agent-dispatch.sh, then verifies with genchi-genbutsu.sh. set -uo pipefail AGENT="${1:?Usage: agent-loop.sh [num-workers]}" NUM_WORKERS="${2:-1}" # Resolve agent tool and model from config or fallback case "$AGENT" in claude) TOOL="claude"; MODEL="sonnet" ;; gemini) TOOL="gemini"; MODEL="gemini-2.5-pro-preview-05-06" ;; grok) TOOL="opencode"; MODEL="grok-3-fast" ;; *) TOOL="$AGENT"; MODEL="" ;; esac # === CONFIG === GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}" GITEA_TOKEN="${GITEA_TOKEN:-}" WORKTREE_BASE="$HOME/worktrees" LOG_DIR="$HOME/.hermes/logs" LOCK_DIR="$LOG_DIR/${AGENT}-locks" SKIP_FILE="$LOG_DIR/${AGENT}-skip-list.json" ACTIVE_FILE="$LOG_DIR/${AGENT}-active.json" TIMEOUT=600 COOLDOWN=30 mkdir -p "$LOG_DIR" "$WORKTREE_BASE" "$LOCK_DIR" [ -f "$SKIP_FILE" ] || echo '{}' > "$SKIP_FILE" echo '{}' > "$ACTIVE_FILE" # === SHARED FUNCTIONS === log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${AGENT}: $*" >> "$LOG_DIR/${AGENT}-loop.log" } lock_issue() { local key="$1" mkdir "$LOCK_DIR/$key.lock" 2>/dev/null && echo $$ > "$LOCK_DIR/$key.lock/pid" } unlock_issue() { rm -rf "$LOCK_DIR/$1.lock" 2>/dev/null } mark_skip() { local issue_num="$1" reason="$2" python3 -c " import json, time, fcntl with open('${SKIP_FILE}', 'r+') as f: fcntl.flock(f, fcntl.LOCK_EX) try: skips = json.load(f) except: skips = {} failures = skips.get(str($issue_num), {}).get('failures', 0) + 1 skip_hours = 6 if failures >= 3 else 1 skips[str($issue_num)] = {'until': time.time() + (skip_hours * 3600), 'reason': '$reason', 'failures': failures} f.seek(0); f.truncate() json.dump(skips, f, indent=2) " 2>/dev/null } get_next_issue() { python3 -c " import json, sys, time, urllib.request, os token = '${GITEA_TOKEN}' base = '${GITEA_URL}' repos = ['Timmy_Foundation/the-nexus', 'Timmy_Foundation/timmy-config', 'Timmy_Foundation/hermes-agent'] try: with open('${SKIP_FILE}') as f: skips = json.load(f) except: skips = {} try: with open('${ACTIVE_FILE}') as f: active = json.load(f); active_issues = {v['issue'] for v in active.values()} except: active_issues = set() all_issues = [] for repo in repos: url = f'{base}/api/v1/repos/{repo}/issues?state=open&type=issues&limit=50&sort=created' req = urllib.request.Request(url, headers={'Authorization': f'token {token}'}) try: resp = urllib.request.urlopen(req, timeout=10) issues = json.loads(resp.read()) for i in issues: i['_repo'] = repo all_issues.extend(issues) except: continue for i in sorted(all_issues, key=lambda x: x['title'].lower()): assignees = [a['login'] for a in (i.get('assignees') or [])] if assignees and '${AGENT}' not in assignees: continue num_str = str(i['number']) if num_str in active_issues: continue if skips.get(num_str, {}).get('until', 0) > time.time(): continue lock = '${LOCK_DIR}/' + i['_repo'].replace('/', '-') + '-' + num_str + '.lock' if os.path.isdir(lock): continue owner, name = i['_repo'].split('/') print(json.dumps({'number': i['number'], 'title': i['title'], 'repo_owner': owner, 'repo_name': name, 'repo': i['_repo']})) sys.exit(0) print('null') " 2>/dev/null } # === WORKER FUNCTION === run_worker() { local worker_id="$1" log "WORKER-${worker_id}: Started" while true; do issue_json=$(get_next_issue) if [ "$issue_json" = "null" ] || [ -z "$issue_json" ]; then sleep 30 continue fi issue_num=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])") issue_title=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['title'])") repo_owner=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_owner'])") repo_name=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_name'])") issue_key="${repo_owner}-${repo_name}-${issue_num}" branch="${AGENT}/issue-${issue_num}" worktree="${WORKTREE_BASE}/${AGENT}-w${worker_id}-${issue_num}" if ! lock_issue "$issue_key"; then sleep 5 continue fi log "WORKER-${worker_id}: === ISSUE #${issue_num}: ${issue_title} (${repo_owner}/${repo_name}) ===" # Clone / checkout rm -rf "$worktree" 2>/dev/null CLONE_URL="http://${AGENT}:${GITEA_TOKEN}@143.198.27.163:3000/${repo_owner}/${repo_name}.git" if git ls-remote --heads "$CLONE_URL" "$branch" 2>/dev/null | grep -q "$branch"; then git clone --depth=50 -b "$branch" "$CLONE_URL" "$worktree" >/dev/null 2>&1 else git clone --depth=1 -b main "$CLONE_URL" "$worktree" >/dev/null 2>&1 cd "$worktree" && git checkout -b "$branch" >/dev/null 2>&1 fi cd "$worktree" # Generate prompt prompt=$(bash "$(dirname "$0")/agent-dispatch.sh" "$AGENT" "$issue_num" "${repo_owner}/${repo_name}") CYCLE_START=$(date +%s) set +e if [ "$TOOL" = "claude" ]; then env -u CLAUDECODE gtimeout "$TIMEOUT" claude \ --print --model "$MODEL" --dangerously-skip-permissions \ -p "$prompt" > "$LOG_DIR/${AGENT}-${issue_num}.log" 2>&1 elif [ "$TOOL" = "gemini" ]; then gtimeout "$TIMEOUT" gemini -p "$prompt" --yolo \ > "$LOG_DIR/${AGENT}-${issue_num}.log" 2>&1 else gtimeout "$TIMEOUT" "$TOOL" "$prompt" \ > "$LOG_DIR/${AGENT}-${issue_num}.log" 2>&1 fi exit_code=$? set -e CYCLE_END=$(date +%s) CYCLE_DURATION=$((CYCLE_END - CYCLE_START)) # Salvage cd "$worktree" 2>/dev/null || true DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') if [ "${DIRTY:-0}" -gt 0 ]; then git add -A 2>/dev/null git commit -m "WIP: ${AGENT} progress on #${issue_num} Automated salvage commit — agent session ended (exit $exit_code)." 2>/dev/null || true fi UNPUSHED=$(git log --oneline "origin/main..HEAD" 2>/dev/null | wc -l | tr -d ' ') if [ "${UNPUSHED:-0}" -gt 0 ]; then git push -u origin "$branch" 2>/dev/null && \ log "WORKER-${worker_id}: Pushed $UNPUSHED commit(s) on $branch" || \ log "WORKER-${worker_id}: Push failed for $branch" fi # Create PR if needed pr_num=$(curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls?state=open&head=${repo_owner}:${branch}&limit=1" \ -H "Authorization: token ${GITEA_TOKEN}" | python3 -c " import sys,json prs = json.load(sys.stdin) print(prs[0]['number'] if prs else '') " 2>/dev/null) if [ -z "$pr_num" ] && [ "${UNPUSHED:-0}" -gt 0 ]; then pr_num=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(python3 -c " import json print(json.dumps({ 'title': '${AGENT}: Issue #${issue_num}', 'head': '${branch}', 'base': 'main', 'body': 'Automated PR for issue #${issue_num}.\nExit code: ${exit_code}' })) ")" | python3 -c "import sys,json; print(json.load(sys.stdin).get('number',''))" 2>/dev/null) [ -n "$pr_num" ] && log "WORKER-${worker_id}: Created PR #${pr_num} for issue #${issue_num}" fi # ── Genchi Genbutsu: verify world state before declaring success ── VERIFIED="false" if [ "$exit_code" -eq 0 ]; then log "WORKER-${worker_id}: SUCCESS #${issue_num} — running genchi-genbutsu" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if verify_result=$("$SCRIPT_DIR/genchi-genbutsu.sh" "$repo_owner" "$repo_name" "$issue_num" "$branch" "$AGENT" 2>/dev/null); then VERIFIED="true" log "WORKER-${worker_id}: VERIFIED #${issue_num}" if [ -n "$pr_num" ]; then curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}/merge" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"Do": "squash"}' >/dev/null 2>&1 || true curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"state": "closed"}' >/dev/null 2>&1 || true log "WORKER-${worker_id}: PR #${pr_num} merged, issue #${issue_num} closed" fi consecutive_failures=0 else verify_details=$(echo "$verify_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('details','unknown'))" 2>/dev/null || echo "unverified") log "WORKER-${worker_id}: UNVERIFIED #${issue_num} — $verify_details" mark_skip "$issue_num" "unverified" 1 consecutive_failures=$((consecutive_failures + 1)) fi elif [ "$exit_code" -eq 124 ]; then log "WORKER-${worker_id}: TIMEOUT #${issue_num} (work saved in PR)" consecutive_failures=$((consecutive_failures + 1)) else log "WORKER-${worker_id}: FAILED #${issue_num} exit ${exit_code} (work saved in PR)" consecutive_failures=$((consecutive_failures + 1)) fi # ── METRICS ── python3 -c " import json, datetime print(json.dumps({ 'ts': datetime.datetime.utcnow().isoformat() + 'Z', 'agent': '${AGENT}', 'worker': $worker_id, 'issue': $issue_num, 'repo': '${repo_owner}/${repo_name}', 'outcome': 'success' if $exit_code == 0 else 'timeout' if $exit_code == 124 else 'failed', 'exit_code': $exit_code, 'duration_s': $CYCLE_DURATION, 'pr': '${pr_num:-}', 'verified': ${VERIFIED:-false} })) " >> "$LOG_DIR/${AGENT}-metrics.jsonl" 2>/dev/null rm -rf "$worktree" 2>/dev/null unlock_issue "$issue_key" sleep "$COOLDOWN" done } # === MAIN === log "=== Agent Loop Started — ${AGENT} with ${NUM_WORKERS} worker(s) ===" rm -rf "$LOCK_DIR"/*.lock 2>/dev/null for i in $(seq 1 "$NUM_WORKERS"); do run_worker "$i" & log "Launched worker $i (PID $!)" sleep 3 done wait