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

192 lines
6.5 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# ── Timmy Development Loop v2 ──────────────────────────────────────────
# Runs forever. Each cycle: Timmy triages → Hermes picks work → execute
# → Timmy reviews → merge. State in .loop/state.json.
# ───────────────────────────────────────────────────────────────────────
set -uo pipefail
export PATH="$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
REPO="$HOME/Timmy-Time-dashboard"
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
MAX_CYCLE_TIME=1200 # 20 min — enough for complex issues
CLAIM_TTL_SECONDS=3600 # 1 hour — stale claims auto-expire
# 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
}
# ── Main Loop ─────────────────────────────────────────────────────────
log "Timmy development loop v2 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"'
# ── Pre-cycle housekeeping ────────────────────────────────────────
expire_claims
# ── Build the prompt with time budget ──────────────────────────────
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.
$PROMPT"
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)\""
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)
"
# ── Cleanup on failure ────────────────────────────────────────
cleanup_cycle "$CYCLE"
fi
log "Cooling down ${COOLDOWN}s before next cycle..."
sleep "$COOLDOWN"
done