Files
hermes-config/bin/timmy-loop.sh

309 lines
11 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
# ── Timmy Development Loop v3 ──────────────────────────────────────────
# Runs forever. Each cycle: triage → Hermes executes → retro → repeat.
#
# TRIAGE (two tiers):
# Fast (every 5 cycles): mechanical scoring → .loop/queue.json
# Deep (every 20 cycles): Hermes reads code + consults Timmy → refined queue
#
# RETRO (every cycle): structured log → .loop/retro/cycles.jsonl
# ───────────────────────────────────────────────────────────────────────
set -uo pipefail
export PATH="$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
REPO="$HOME/.hermes"
STATE="$REPO/loop/state.json"
LOG_DIR="$REPO/loop/logs"
CLAIMS="$REPO/loop/claims.json"
PROMPT_FILE="$HOME/.hermes/bin/timmy-loop-prompt.md"
LOCKFILE="/tmp/timmy-loop.lock"
COOLDOWN=3
IDLE_COOLDOWN=60 # 1 min base when idle (no queue / no work)
IDLE_MAX_COOLDOWN=600 # 10 min max backoff when idle
IDLE_STREAK=0 # consecutive idle cycles
MAX_CYCLE_TIME=1200 # 20 min — enough for complex issues
CLAIM_TTL_SECONDS=3600 # 1 hour — stale claims auto-expire
FAST_TRIAGE_INTERVAL=5 # mechanical scoring every N cycles
DEEP_TRIAGE_INTERVAL=20 # Hermes+Timmy deep triage every N cycles
TRIAGE_SCRIPT="$REPO/scripts/triage_score.py"
RETRO_SCRIPT="$REPO/scripts/cycle_retro.py"
DEEP_TRIAGE_SCRIPT="$REPO/scripts/deep_triage.sh"
QUEUE_FILE="$REPO/loop/queue.json"
# macOS doesn't have timeout; use perl fallback
if ! command -v timeout &>/dev/null; then
timeout() {
local duration="$1"; shift
perl -e "alarm $duration; exec @ARGV" -- "$@"
}
fi
mkdir -p "$LOG_DIR"
# ── Single-instance lock ──────────────────────────────────────────────
if [ -f "$LOCKFILE" ]; then
PID=$(cat "$LOCKFILE" 2>/dev/null)
if kill -0 "$PID" 2>/dev/null; then
echo "[loop] Already running (PID $PID). Exiting."
exit 0
fi
rm -f "$LOCKFILE"
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# ── Helpers ───────────────────────────────────────────────────────────
update_state() {
local key="$1" value="$2"
python3 -c "
import json
with open('$STATE') as f: s = json.load(f)
s['$key'] = $value
with open('$STATE', 'w') as f: json.dump(s, f, indent=2)
"
}
log() {
echo "[$(date '+%H:%M:%S')] $*"
}
# ── Expire stale claims ──────────────────────────────────────────────
expire_claims() {
python3 -c "
import json
from datetime import datetime, timezone, timedelta
TTL = timedelta(seconds=$CLAIM_TTL_SECONDS)
now = datetime.now(timezone.utc)
try:
with open('$CLAIMS') as f:
claims = json.load(f)
except:
claims = {}
expired = []
active = {}
for issue, info in claims.items():
if isinstance(info, dict):
claimed_at = info.get('at', '')
try:
dt = datetime.fromisoformat(claimed_at.replace('Z', '+00:00'))
if now - dt > TTL:
expired.append(issue)
continue
except:
pass
active[issue] = info
if expired:
print(f'[claims] Expired {len(expired)} stale claims: {expired}')
with open('$CLAIMS', 'w') as f:
json.dump(active, f, indent=2)
else:
print('[claims] No stale claims')
" 2>&1
}
# ── Cleanup after timeout/failure ─────────────────────────────────────
cleanup_cycle() {
local cycle_num="$1"
local worktree="/tmp/timmy-cycle-${cycle_num}"
# Remove worktree if it exists
if [ -d "$worktree" ]; then
log "Cleaning up worktree $worktree"
cd "$REPO" && git worktree remove "$worktree" --force 2>/dev/null || true
fi
# Release any claims held by this cycle
python3 -c "
import json
try:
with open('$CLAIMS') as f:
claims = json.load(f)
released = []
active = {}
for issue, info in claims.items():
agent = info.get('by', '') if isinstance(info, dict) else ''
if 'loop' in agent or 'hermes' in agent.lower():
released.append(issue)
else:
active[issue] = info
if released:
with open('$CLAIMS', 'w') as f:
json.dump(active, f, indent=2)
print(f'[cleanup] Released claims: {released}')
except Exception as e:
print(f'[cleanup] Claim cleanup failed: {e}')
" 2>&1
}
# ── Fast triage (mechanical scoring) ──────────────────────────────────
run_fast_triage() {
if [ -f "$TRIAGE_SCRIPT" ]; then
log "Running fast triage..."
python3 "$TRIAGE_SCRIPT" 2>&1
else
log "WARNING: triage script not found at $TRIAGE_SCRIPT"
fi
}
# ── Deep triage (Hermes + Timmy) ─────────────────────────────────────
run_deep_triage() {
if [ -f "$DEEP_TRIAGE_SCRIPT" ]; then
log "Running deep triage (Hermes + Timmy)..."
timeout 600 bash "$DEEP_TRIAGE_SCRIPT" 2>&1
else
log "WARNING: deep triage script not found at $DEEP_TRIAGE_SCRIPT"
fi
}
# ── Cycle retrospective ─────────────────────────────────────────────
log_retro() {
# Usage: log_retro <success|failure> [extra args for cycle_retro.py]
local outcome="$1"; shift
if [ -f "$RETRO_SCRIPT" ]; then
local duration=$(( $(date +%s) - CYCLE_START ))
if [ "$outcome" = "success" ]; then
python3 "$RETRO_SCRIPT" --cycle "$CYCLE" --success \
--duration "$duration" "$@" 2>&1
else
python3 "$RETRO_SCRIPT" --cycle "$CYCLE" --failure \
--duration "$duration" "$@" 2>&1
fi
fi
}
# ── Inject queue context into prompt ─────────────────────────────────
get_queue_context() {
if [ -f "$QUEUE_FILE" ]; then
python3 -c "
import json
with open('$QUEUE_FILE') as f: q = json.load(f)
if not q:
print('Queue is empty. Check open issues on Gitea and pick the highest-priority one.')
else:
print(f'PRIORITIZED QUEUE ({len(q)} ready issues):')
for i, item in enumerate(q[:8]):
flag = 'BUG' if item.get('type') == 'bug' else item.get('type', '?').upper()
print(f' {i+1}. #{item[\"issue\"]} [{flag}] score={item[\"score\"]} — {item.get(\"title\",\"?\")[:60]}')
if item.get('files'):
print(f' files: {\", \".join(item[\"files\"][:3])}')
if len(q) > 8:
print(f' ... +{len(q)-8} more')
print()
print(f'Pick from the TOP of this queue. Issue #{q[0][\"issue\"]} is highest priority.')
" 2>/dev/null
else
echo "No queue file. Check open issues on Gitea and pick the highest-priority one."
fi
}
# ── Main Loop ─────────────────────────────────────────────────────────
log "Timmy development loop v3 starting. PID $$"
log "Timeout: ${MAX_CYCLE_TIME}s | Cooldown: ${COOLDOWN}s | Claim TTL: ${CLAIM_TTL_SECONDS}s"
log "Repo: $REPO"
update_state "started_at" "\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
update_state "status" '"running"'
CYCLE=$(python3 -c "import json; print(json.load(open('$STATE'))['cycle'])")
while true; do
# Check for stop file
if [ -f "$REPO/loop/STOP" ]; then
log "STOP file found. Halting loop."
update_state "status" '"stopped"'
break
fi
CYCLE=$((CYCLE + 1))
CYCLE_LOG="$LOG_DIR/cycle-$(printf '%04d' $CYCLE).log"
log "━━━ Cycle $CYCLE ━━━"
update_state "cycle" "$CYCLE"
update_state "status" '"working"'
CYCLE_START=$(date +%s)
# ── Pre-cycle housekeeping ────────────────────────────────────────
expire_claims
# ── Triage (fast every 5, deep every 20) ─────────────────────────
if (( CYCLE % DEEP_TRIAGE_INTERVAL == 0 )); then
run_deep_triage
elif (( CYCLE % FAST_TRIAGE_INTERVAL == 0 )); then
run_fast_triage
fi
# ── Build the prompt with time budget + queue ────────────────────
QUEUE_CONTEXT=$(get_queue_context)
PROMPT=$(cat "$PROMPT_FILE")
PROMPT="TIME BUDGET: You have $((MAX_CYCLE_TIME / 60)) minutes for this cycle. Plan accordingly — do not start work you cannot finish.
$QUEUE_CONTEXT
$PROMPT"
# ── Idle detection: skip cycle if queue is empty ──────────────────
QUEUE_EMPTY=false
if [ -f "$QUEUE_FILE" ]; then
QUEUE_LEN=$(python3 -c "import json; q=json.load(open('$QUEUE_FILE')); print(len(q))" 2>/dev/null || echo "0")
if [ "$QUEUE_LEN" = "0" ]; then
QUEUE_EMPTY=true
fi
else
QUEUE_EMPTY=true
fi
if [ "$QUEUE_EMPTY" = "true" ]; then
IDLE_STREAK=$((IDLE_STREAK + 1))
# Exponential backoff: 60s, 120s, 240s, ... capped at 600s
WAIT=$((IDLE_COOLDOWN * (2 ** (IDLE_STREAK - 1))))
if [ "$WAIT" -gt "$IDLE_MAX_COOLDOWN" ]; then
WAIT=$IDLE_MAX_COOLDOWN
fi
log "Queue empty (idle streak: $IDLE_STREAK). Sleeping ${WAIT}s before next triage."
update_state "status" '"idle"'
# Do NOT log a retro entry — empty cycles are not cycles
sleep "$WAIT"
continue
fi
# Reset idle streak — we have work
IDLE_STREAK=0
log "Spawning hermes for cycle $CYCLE..."
# Run hermes with timeout — tee to log AND stdout for live visibility
if timeout "$MAX_CYCLE_TIME" hermes chat --yolo -q "$PROMPT" 2>&1 | tee "$CYCLE_LOG"; then
log "Cycle $CYCLE completed successfully"
update_state "status" '"idle"'
update_state "last_completed" "\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
# ── Cycle retro (success) ────────────────────────────────────
log_retro success
else
EXIT_CODE=$?
log "Cycle $CYCLE exited with code $EXIT_CODE"
update_state "status" '"error"'
python3 -c "
import json
with open('$STATE') as f: s = json.load(f)
errs = s.get('errors', [])
errs.append({'cycle': $CYCLE, 'code': $EXIT_CODE, 'time': '$(date -u +%Y-%m-%dT%H:%M:%SZ)'})
s['errors'] = errs[-10:]
with open('$STATE', 'w') as f: json.dump(s, f, indent=2)
"
# ── Cycle retro (failure) ────────────────────────────────────
log_retro failure --reason "exit code $EXIT_CODE"
# ── Cleanup on failure ───────────────────────────────────────
cleanup_cycle "$CYCLE"
fi
log "Cooling down ${COOLDOWN}s before next cycle..."
sleep "$COOLDOWN"
done