Timmy doesn't have Gitea access in CLI chat mode. Instead of asking him to check for PRs (which he can't), we fetch the latest merged PR and commit summary ourselves and ask for his opinion on the change.
225 lines
8.4 KiB
Bash
Executable File
225 lines
8.4 KiB
Bash
Executable File
#!/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=30
|
|
MAX_CYCLE_TIME=1200 # 20 min — enough for complex issues
|
|
CLAIM_TTL_SECONDS=3600 # 1 hour — stale claims auto-expire
|
|
TIMMY="$REPO/.venv/bin/timmy"
|
|
|
|
# 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
|
|
}
|
|
|
|
# ── Ask Timmy for triage (non-blocking) ───────────────────────────────
|
|
ask_timmy() {
|
|
local question="$1"
|
|
local result
|
|
# 45s timeout — if Timmy hangs, skip gracefully
|
|
result=$(timeout 45 "$TIMMY" chat --session-id loop "$question" 2>/dev/null | grep -v "^WARNING" | grep -v "^$" | head -20)
|
|
if [ -n "$result" ]; then
|
|
echo "$result"
|
|
else
|
|
echo "(Timmy unavailable)"
|
|
fi
|
|
}
|
|
|
|
# ── 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
|
|
|
|
# ── Ask Timmy for input (if available) ────────────────────────────
|
|
log "Asking Timmy for triage input..."
|
|
TIMMY_INPUT=$(ask_timmy "The development loop is starting cycle $CYCLE. Look at the open issues on Gitea and tell me: which issue should we work on next and why? Consider priority, dependencies, and what would help you most. Be brief — two sentences max.")
|
|
log "Timmy says: $TIMMY_INPUT"
|
|
|
|
# ── Build the prompt with time budget and Timmy's input ───────────
|
|
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.
|
|
|
|
TIMMY'S TRIAGE INPUT (from Timmy himself):
|
|
$TIMMY_INPUT
|
|
|
|
$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)\""
|
|
|
|
# ── Post-cycle: Ask Timmy to review ───────────────────────────
|
|
log "Asking Timmy to review cycle output..."
|
|
# Get the latest PR diff and feed it to Timmy directly
|
|
LATEST_PR=$(curl -s "http://localhost:3000/api/v1/repos/rockachopa/Timmy-time-dashboard/pulls?state=closed&sort=created&limit=1" \
|
|
-H "Authorization: token $(cat ~/.hermes/gitea_token)" 2>/dev/null | python3 -c "
|
|
import json,sys
|
|
prs=json.load(sys.stdin)
|
|
if prs: print(f'PR #{prs[0][\"number\"]}: {prs[0][\"title\"]}')
|
|
else: print('none')
|
|
" 2>/dev/null)
|
|
DIFF_SUMMARY=$(cd "$REPO" && git log --oneline -1 2>/dev/null)
|
|
REVIEW=$(ask_timmy "The dev loop just merged $LATEST_PR (commit: $DIFF_SUMMARY). Based on what you know about your own architecture, does this sound like a good change? Any concerns? Two sentences max.")
|
|
log "Timmy's review: $REVIEW"
|
|
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
|