219 lines
8.1 KiB
Bash
Executable File
219 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# timmy-orchestrator.sh — Timmy's orchestration loop
|
|
# Uses Hermes CLI plus workforce-manager to triage and review.
|
|
# Timmy is the brain. Other agents are the hands.
|
|
|
|
set -uo pipefail
|
|
|
|
LOG_DIR="$HOME/.hermes/logs"
|
|
LOG="$LOG_DIR/timmy-orchestrator.log"
|
|
PIDFILE="$LOG_DIR/timmy-orchestrator.pid"
|
|
GITEA_URL="http://143.198.27.163:3000"
|
|
GITEA_TOKEN=$(cat "$HOME/.hermes/gitea_token_vps" 2>/dev/null) # Timmy token, NOT rockachopa
|
|
CYCLE_INTERVAL=300
|
|
HERMES_TIMEOUT=180
|
|
AUTO_ASSIGN_UNASSIGNED="${AUTO_ASSIGN_UNASSIGNED:-0}" # 0 = report only, 1 = mutate Gitea assignments
|
|
|
|
mkdir -p "$LOG_DIR"
|
|
|
|
# Single instance guard
|
|
if [ -f "$PIDFILE" ]; then
|
|
old_pid=$(cat "$PIDFILE")
|
|
if kill -0 "$old_pid" 2>/dev/null; then
|
|
echo "Timmy already running (PID $old_pid)" >&2
|
|
exit 0
|
|
fi
|
|
fi
|
|
echo $$ > "$PIDFILE"
|
|
trap 'rm -f "$PIDFILE"' EXIT
|
|
|
|
log() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] TIMMY: $*" >> "$LOG"
|
|
}
|
|
|
|
REPOS="Timmy_Foundation/the-nexus Timmy_Foundation/timmy-home Timmy_Foundation/timmy-config Timmy_Foundation/hermes-agent"
|
|
|
|
gather_state() {
|
|
local state_dir="/tmp/timmy-state-$$"
|
|
mkdir -p "$state_dir"
|
|
|
|
> "$state_dir/unassigned.txt"
|
|
> "$state_dir/open_prs.txt"
|
|
> "$state_dir/agent_status.txt"
|
|
|
|
for repo in $REPOS; do
|
|
local short=$(echo "$repo" | cut -d/ -f2)
|
|
|
|
# Unassigned issues
|
|
curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_URL/api/v1/repos/$repo/issues?state=open&type=issues&limit=50" 2>/dev/null | \
|
|
python3 -c "
|
|
import sys,json
|
|
for i in json.load(sys.stdin):
|
|
if not i.get('assignees'):
|
|
print(f'REPO={\"$repo\"} NUM={i[\"number\"]} TITLE={i[\"title\"]}')" >> "$state_dir/unassigned.txt" 2>/dev/null
|
|
|
|
# Open PRs
|
|
curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_URL/api/v1/repos/$repo/pulls?state=open&limit=30" 2>/dev/null | \
|
|
python3 -c "
|
|
import sys,json
|
|
for p in json.load(sys.stdin):
|
|
print(f'REPO={\"$repo\"} PR={p[\"number\"]} BY={p[\"user\"][\"login\"]} TITLE={p[\"title\"]}')" >> "$state_dir/open_prs.txt" 2>/dev/null
|
|
done
|
|
|
|
echo "Claude workers: $(pgrep -f 'claude.*--print.*--dangerously' 2>/dev/null | wc -l | tr -d ' ')" >> "$state_dir/agent_status.txt"
|
|
echo "Claude loop: $(pgrep -f 'claude-loop.sh' 2>/dev/null | wc -l | tr -d ' ') procs" >> "$state_dir/agent_status.txt"
|
|
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "SUCCESS" | xargs -I{} echo "Claude recent successes: {}" >> "$state_dir/agent_status.txt"
|
|
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "FAILED" | xargs -I{} echo "Claude recent failures: {}" >> "$state_dir/agent_status.txt"
|
|
echo "Kimi loop: $(pgrep -f 'kimi-loop.sh' 2>/dev/null | wc -l | tr -d ' ') procs" >> "$state_dir/agent_status.txt"
|
|
tail -50 "$LOG_DIR/kimi-loop.log" 2>/dev/null | grep -c "SUCCESS" | xargs -I{} echo "Kimi recent successes: {}" >> "$state_dir/agent_status.txt"
|
|
tail -50 "$LOG_DIR/kimi-loop.log" 2>/dev/null | grep -c "FAILED" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
|
tail -1 "$LOG_DIR/kimi-loop.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
|
|
|
echo "$state_dir"
|
|
}
|
|
|
|
run_triage() {
|
|
local state_dir="$1"
|
|
local unassigned_count=$(wc -l < "$state_dir/unassigned.txt" | tr -d ' ')
|
|
local pr_count=$(wc -l < "$state_dir/open_prs.txt" | tr -d ' ')
|
|
|
|
log "Cycle: $unassigned_count unassigned, $pr_count open PRs"
|
|
|
|
# If nothing to do, skip the LLM call
|
|
if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then
|
|
log "Nothing to triage"
|
|
return
|
|
fi
|
|
|
|
# Phase 1: Report unassigned issues by default.
|
|
# Auto-assignment is opt-in because silent queue mutation resurrects old state.
|
|
if [ "$unassigned_count" -gt 0 ]; then
|
|
if [ "$AUTO_ASSIGN_UNASSIGNED" = "1" ]; then
|
|
log "Assigning $unassigned_count issues to claude..."
|
|
while IFS= read -r line; do
|
|
local repo=$(echo "$line" | sed 's/.*REPO=\([^ ]*\).*/\1/')
|
|
local num=$(echo "$line" | sed 's/.*NUM=\([^ ]*\).*/\1/')
|
|
curl -sf -X PATCH "$GITEA_URL/api/v1/repos/$repo/issues/$num" \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"assignees":["claude"]}' >/dev/null 2>&1 && \
|
|
log " Assigned #$num ($repo) to claude"
|
|
done < "$state_dir/unassigned.txt"
|
|
else
|
|
log "Auto-assign disabled: leaving $unassigned_count unassigned issues untouched"
|
|
fi
|
|
fi
|
|
|
|
# Phase 2: PR review via Timmy (LLM)
|
|
if [ "$pr_count" -gt 0 ]; then
|
|
run_pr_review "$state_dir"
|
|
fi
|
|
}
|
|
|
|
run_pr_review() {
|
|
local state_dir="$1"
|
|
local prompt_file="/tmp/timmy-prompt-$$.txt"
|
|
|
|
# Build a review prompt listing all open PRs
|
|
cat > "$prompt_file" <<'HEADER'
|
|
You are Timmy, the orchestrator. Review these open PRs from AI agents.
|
|
|
|
For each PR, you will see the diff. Your job:
|
|
- MERGE if changes look reasonable (most agent PRs are good, merge aggressively)
|
|
- COMMENT if there is a clear problem
|
|
- CLOSE if it is a duplicate or garbage
|
|
|
|
Use these exact curl patterns (replace REPO, NUM):
|
|
Merge: curl -sf -X POST "GITEA/api/v1/repos/REPO/pulls/NUM/merge" -H "Authorization: token TOKEN" -H "Content-Type: application/json" -d '{"Do":"squash"}'
|
|
Comment: curl -sf -X POST "GITEA/api/v1/repos/REPO/pulls/NUM/comments" -H "Authorization: token TOKEN" -H "Content-Type: application/json" -d '{"body":"feedback"}'
|
|
Close: curl -sf -X PATCH "GITEA/api/v1/repos/REPO/pulls/NUM" -H "Authorization: token TOKEN" -H "Content-Type: application/json" -d '{"state":"closed"}'
|
|
|
|
HEADER
|
|
|
|
# Replace placeholders
|
|
sed -i '' "s|GITEA|$GITEA_URL|g; s|TOKEN|$GITEA_TOKEN|g" "$prompt_file"
|
|
|
|
# Add each PR with its diff (up to 10 PRs per cycle)
|
|
local count=0
|
|
while IFS= read -r line && [ "$count" -lt 10 ]; do
|
|
local repo=$(echo "$line" | sed 's/.*REPO=\([^ ]*\).*/\1/')
|
|
local pr_num=$(echo "$line" | sed 's/.*PR=\([^ ]*\).*/\1/')
|
|
local by=$(echo "$line" | sed 's/.*BY=\([^ ]*\).*/\1/')
|
|
local title=$(echo "$line" | sed 's/.*TITLE=//')
|
|
|
|
[ -z "$pr_num" ] && continue
|
|
|
|
local diff
|
|
diff=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Accept: application/diff" \
|
|
"$GITEA_URL/api/v1/repos/$repo/pulls/$pr_num" 2>/dev/null | head -150)
|
|
|
|
[ -z "$diff" ] && continue
|
|
|
|
echo "" >> "$prompt_file"
|
|
echo "=== PR #$pr_num in $repo by $by ===" >> "$prompt_file"
|
|
echo "Title: $title" >> "$prompt_file"
|
|
echo "Diff (first 150 lines):" >> "$prompt_file"
|
|
echo "$diff" >> "$prompt_file"
|
|
echo "=== END PR #$pr_num ===" >> "$prompt_file"
|
|
|
|
count=$((count + 1))
|
|
done < "$state_dir/open_prs.txt"
|
|
|
|
if [ "$count" -eq 0 ]; then
|
|
rm -f "$prompt_file"
|
|
return
|
|
fi
|
|
|
|
echo "" >> "$prompt_file"
|
|
cat >> "$prompt_file" <<'FOOTER'
|
|
INSTRUCTIONS: For EACH PR above, do ONE of the following RIGHT NOW using your terminal tool:
|
|
- Run the merge curl command if the diff looks good
|
|
- Run the close curl command if it is a duplicate or garbage
|
|
- Run the comment curl command only if there is a clear bug
|
|
|
|
IMPORTANT: Actually run the curl commands. Do not just describe what you would do. Finish means the PR world-state changed.
|
|
FOOTER
|
|
|
|
local prompt_text
|
|
prompt_text=$(cat "$prompt_file")
|
|
rm -f "$prompt_file"
|
|
|
|
log "Reviewing $count PRs..."
|
|
local result
|
|
result=$(timeout "$HERMES_TIMEOUT" hermes chat -q "$prompt_text" -Q --yolo 2>&1)
|
|
local exit_code=$?
|
|
|
|
if [ "$exit_code" -eq 0 ]; then
|
|
log "PR review complete"
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $result" >> "$LOG_DIR/timmy-reviews.log"
|
|
else
|
|
log "PR review failed (exit $exit_code)"
|
|
fi
|
|
}
|
|
|
|
# === MAIN LOOP ===
|
|
log "=== Timmy Orchestrator Started (PID $$) ==="
|
|
log "Cycle: ${CYCLE_INTERVAL}s | Auto-assign: ${AUTO_ASSIGN_UNASSIGNED} | Inference surface: Hermes CLI"
|
|
|
|
WORKFORCE_CYCLE=0
|
|
|
|
while true; do
|
|
state_dir=$(gather_state)
|
|
run_triage "$state_dir"
|
|
rm -rf "$state_dir"
|
|
|
|
# Run workforce manager every 3rd cycle (~15 min)
|
|
WORKFORCE_CYCLE=$((WORKFORCE_CYCLE + 1))
|
|
if [ $((WORKFORCE_CYCLE % 3)) -eq 0 ]; then
|
|
log "Running workforce manager..."
|
|
python3 "$HOME/.hermes/bin/workforce-manager.py" all >> "$LOG_DIR/workforce-manager.log" 2>&1
|
|
log "Workforce manager complete"
|
|
fi
|
|
|
|
log "Sleeping ${CYCLE_INTERVAL}s"
|
|
sleep "$CYCLE_INTERVAL"
|
|
done
|