diff --git a/.gitignore b/.gitignore index bc7470b..6d702bf 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ bin/* !bin/kimi-loop.sh !bin/timmy-loopstat.sh !bin/start-dashboard.sh +!bin/gemini-loop.sh # ── Queue (transient task queue) ───────────────────────────────────── queue/ diff --git a/bin/gemini-loop.sh b/bin/gemini-loop.sh new file mode 100755 index 0000000..9fd2a8b --- /dev/null +++ b/bin/gemini-loop.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# gemini-loop.sh — Dropout-proof Gemini code agent dispatch loop +# Picks an open issue from Gitea, creates a worktree, runs Gemini Code CLI, +# handles failures gracefully, and loops forever. +# +# Dropout-proof means: +# - If Gemini Code crashes/hangs, we kill it and move on +# - If worktree creation fails, skip and retry +# - If push fails, log and continue +# - Exponential backoff on repeated failures +# - Clean up worktrees after PR is created + +set -euo pipefail + +# === CONFIG === +REPO_DIR="$HOME/worktrees/gemini-repo" +WORKTREE_BASE="$HOME/worktrees" +GITEA_URL="http://143.198.27.163:3000" +GITEA_TOKEN=$(cat "$HOME/.hermes/gemini_token") +REPO_OWNER="rockachopa" +REPO_NAME="Timmy-time-dashboard" +GEMINI_TIMEOUT=600 # 10 min per issue +COOLDOWN=30 # seconds between issues +MAX_FAILURES=5 # consecutive failures before long sleep +LONG_SLEEP=300 # 5 min backoff on repeated failures +LOG_DIR="$HOME/.hermes/logs" +SKIP_FILE="$LOG_DIR/gemini-skip-list.json" # issues to skip temporarily + +mkdir -p "$LOG_DIR" "$WORKTREE_BASE" + +# Initialize skip file if missing +[ -f "$SKIP_FILE" ] || echo '{}' > "$SKIP_FILE" + +# === STATE === +failure_count=0 +issues_completed=0 + +# === SKIP LIST FUNCTIONS === +is_skipped() { + local issue_num="$1" + python3 -c " +import json, time, sys +try: + with open('$SKIP_FILE') as f: skips = json.load(f) +except: skips = {} +entry = skips.get(str($issue_num), {}) +if entry and entry.get('until', 0) > time.time(): + print('skip') + sys.exit(0) +# Expired or not found — clean up and allow +if str($issue_num) in skips: + del skips[str($issue_num)] + with open('$SKIP_FILE', 'w') as f: json.dump(skips, f) +print('ok') +" 2>/dev/null +} + +mark_skip() { + local issue_num="$1" + local reason="$2" + local skip_hours="${3:-1}" # default 1 hour + python3 -c " +import json, time +try: + with open('$SKIP_FILE') as f: skips = json.load(f) +except: skips = {} +skips[str($issue_num)] = { + 'until': time.time() + ($skip_hours * 3600), + 'reason': '$reason', + 'failures': skips.get(str($issue_num), {}).get('failures', 0) + 1 +} +# If 3+ failures, skip for 6 hours instead +if skips[str($issue_num)]['failures'] >= 3: + skips[str($issue_num)]['until'] = time.time() + (6 * 3600) +with open('$SKIP_FILE', 'w') as f: json.dump(skips, f, indent=2) +" 2>/dev/null + log "SKIP: #${issue_num} added to skip list — ${reason}" +} + +log() { + local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*" + echo "$msg" >> "$LOG_DIR/gemini-loop.log" +} + +cleanup_worktree() { + local wt="$1" + local branch="$2" + if [ -d "$wt" ]; then + cd "$REPO_DIR" + git worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt" + git worktree prune 2>/dev/null + git branch -D "$branch" 2>/dev/null || true + log "Cleaned up worktree: $wt" + fi +} + +get_next_issue() { + # Get open issues ASSIGNED TO GEMINI only — Gemini works its own queue + # NOTE: Gitea's assignee filter is unreliable — we validate in Python + local skip_file="$SKIP_FILE" + curl -sf "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues?state=open&type=issues&limit=50&sort=created" \ + -H "Authorization: token ${GITEA_TOKEN}" | python3 -c " +import sys, json, time + +issues = json.load(sys.stdin) +# Reverse to oldest-first (Gitea returns newest-first) — respects dependency order +issues.reverse() + +# Load skip list +try: + with open('${skip_file}') as f: skips = json.load(f) +except: skips = {} + +for i in issues: + # MUST be assigned to gemini (Gitea filter is broken, validate here) + assignees = [a['login'] for a in (i.get('assignees') or [])] + if 'gemini' not in assignees: + continue + + title = i['title'].lower() + # Skip philosophy, epics, showcases, features (not 10-min code work) + if '[philosophy]' in title: continue + if '[epic]' in title or 'epic:' in title: continue + if '[showcase]' in title: continue + if '[feature]' in title: continue + + # Check skip list + num_str = str(i['number']) + entry = skips.get(num_str, {}) + if entry and entry.get('until', 0) > time.time(): + continue + + print(json.dumps({'number': i['number'], 'title': i['title']})) + sys.exit(0) +print('null') +" 2>/dev/null +} + +build_prompt() { + local issue_num="$1" + local issue_title="$2" + local worktree="$3" + + cat < (#${issue_num})", "body": "Fixes #${issue_num}\n\n", "head": "gemini/issue-${issue_num}", "base": "main"}' + +5. COMMENT on the issue when done: + curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_num}/comments" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"body": "PR created. "}' + +6. FILE NEW ISSUES if you find bugs, missing tests, or improvements while working: + curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"title": "[gemini-generated] ", "body": "<description>"}' + +== RULES == +- Read CLAUDE.md or project README first for conventions +- tox is the ONLY way to run tests/lint/format. Never run pytest/ruff directly. +- Never use --no-verify on git commands. +- If tests fail after 2 attempts, STOP and comment on the issue explaining why. +- Be thorough. If you see something broken nearby, file an issue for it. +PROMPT +} + +# === MAIN LOOP === +log "=== Gemini Loop Started ===" +log "Repo: ${REPO_DIR}" +log "Worktrees: ${WORKTREE_BASE}" + +while true; do + # Check for too many consecutive failures + if [ "$failure_count" -ge "$MAX_FAILURES" ]; then + log "BACKOFF: ${failure_count} consecutive failures. Sleeping ${LONG_SLEEP}s..." + sleep "$LONG_SLEEP" + failure_count=0 + fi + + # Fetch latest main (resilient — never die on git errors) + cd "$REPO_DIR" + timeout 60 git fetch origin main 2>/dev/null || { log "WARN: git fetch failed, continuing anyway"; } + git checkout main 2>/dev/null || true + git reset --hard origin/main 2>/dev/null || true + + # Get next issue + issue_json=$(get_next_issue) + + if [ "$issue_json" = "null" ] || [ -z "$issue_json" ]; then + # Only log idle ONCE, then go quiet until work appears + if [ "${LAST_STATE:-}" != "idle" ]; then + log "Queue empty. Waiting for assignments..." + LAST_STATE="idle" + fi + sleep "$LONG_SLEEP" + continue + fi + LAST_STATE="working" + + 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'])") + branch="gemini/issue-${issue_num}" + worktree="${WORKTREE_BASE}/gemini-${issue_num}" + + log "=== ISSUE #${issue_num}: ${issue_title} ===" + + # Create worktree + if [ -d "$worktree" ]; then + log "Worktree already exists, cleaning..." + cleanup_worktree "$worktree" "$branch" + fi + + cd "$REPO_DIR" + if ! git worktree add "$worktree" -b "$branch" origin/main 2>&1; then + log "ERROR: Failed to create worktree for #${issue_num}" + failure_count=$((failure_count + 1)) + sleep "$COOLDOWN" + continue + fi + + # Configure git remote with gemini's token so it can push + cd "$worktree" + git remote set-url origin "http://gemini:${GITEA_TOKEN}@143.198.27.163:3000/${REPO_OWNER}/${REPO_NAME}.git" + cd "$REPO_DIR" + + # Build prompt + prompt=$(build_prompt "$issue_num" "$issue_title" "$worktree") + + # Run Gemini Code CLI with timeout + log "Launching Gemini Code for #${issue_num} (timeout: ${GEMINI_TIMEOUT}s)..." + + set +e + cd "$worktree" + gtimeout "$GEMINI_TIMEOUT" gemini \ + --print \ + --quiet \ + -w "$worktree" \ + -p "$prompt" \ + </dev/null 2>&1 | tee "$LOG_DIR/gemini-${issue_num}.log" + exit_code=${PIPESTATUS[0]} + cd "$REPO_DIR" + set -e + + if [ "$exit_code" -eq 0 ]; then + log "SUCCESS: #${issue_num} completed — attempting auto-merge..." + + # Find and merge the PR gemini created + 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) +if prs: print(prs[0]['number']) +else: print('') +" 2>/dev/null) + + if [ -n "$pr_num" ]; then + merge_result=$(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"}' 2>&1) || true + log " PR #${pr_num} merge attempted" + + # Close the issue (Gitea auto-close via "Fixes #N" is unreliable) + 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 " Issue #${issue_num} closed" + else + log " WARN: No open PR found for branch ${branch}" + fi + + failure_count=0 + issues_completed=$((issues_completed + 1)) + log "Stats: ${issues_completed} issues completed this session" + elif [ "$exit_code" -eq 124 ]; then + log "TIMEOUT: #${issue_num} exceeded ${GEMINI_TIMEOUT}s" + mark_skip "$issue_num" "timeout" 1 + failure_count=$((failure_count + 1)) + else + log "FAILED: #${issue_num} exited with code ${exit_code}" + mark_skip "$issue_num" "exit_code_${exit_code}" 1 + failure_count=$((failure_count + 1)) + fi + + # Clean up worktree + cleanup_worktree "$worktree" "$branch" + + # Cooldown + log "Cooling down ${COOLDOWN}s before next issue..." + sleep "$COOLDOWN" +done