Some checks failed
Architecture Lint / Linter Tests (push) Successful in 17s
Smoke Test / smoke (push) Failing after 12s
Validate Config / YAML Lint (push) Failing after 10s
Validate Config / JSON Validate (push) Successful in 16s
Validate Config / Python Syntax & Import Check (push) Failing after 37s
Validate Config / Python Test Suite (push) Has been skipped
Validate Config / Cron Syntax Check (push) Successful in 15s
Validate Config / Shell Script Lint (push) Failing after 46s
Validate Config / Deploy Script Dry Run (push) Successful in 10s
Validate Config / Playbook Schema Validation (push) Successful in 16s
Architecture Lint / Lint Repository (push) Failing after 13s
- dispatch_router.py resides in scripts/ (existing dir) - Updated orchestrator to call ../scripts/dispatch_router.py
285 lines
12 KiB
Bash
Executable File
285 lines
12 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\n\nSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
LOG_DIR="$HOME/.hermes/logs"
|
||
LOG="$LOG_DIR/timmy-orchestrator.log"
|
||
PIDFILE="$LOG_DIR/timmy-orchestrator.pid"
|
||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||
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"
|
||
> "$state_dir/uncommitted_work.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 heartbeat launchd: $(launchctl list 2>/dev/null | grep -c 'ai.timmy.kimi-heartbeat' | tr -d ' ') job" >> "$state_dir/agent_status.txt"
|
||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "DISPATCHED:" | xargs -I{} echo "Kimi recent dispatches: {}" >> "$state_dir/agent_status.txt"
|
||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
||
tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
||
|
||
# Scan worktrees for uncommitted work
|
||
for wt_dir in "$HOME/worktrees"/*/; do
|
||
[ -d "$wt_dir" ] || continue
|
||
[ -d "$wt_dir/.git" ] || continue
|
||
local dirty
|
||
dirty=$(cd "$wt_dir" && git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||
if [ "${dirty:-0}" -gt 0 ]; then
|
||
local branch
|
||
branch=$(cd "$wt_dir" && git branch --show-current 2>/dev/null || echo "?")
|
||
local age=""
|
||
local last_commit
|
||
last_commit=$(cd "$wt_dir" && git log -1 --format=%ct 2>/dev/null || echo 0)
|
||
local now=$(date +%s)
|
||
local stale_mins=$(( (now - last_commit) / 60 ))
|
||
echo "DIR=$wt_dir BRANCH=$branch DIRTY=$dirty STALE=${stale_mins}m" >> "$state_dir/uncommitted_work.txt"
|
||
fi
|
||
done
|
||
|
||
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"
|
||
|
||
# Check for uncommitted work — nag if stale
|
||
local uncommitted_count
|
||
uncommitted_count=$(wc -l < "$state_dir/uncommitted_work.txt" 2>/dev/null | tr -d " " || echo 0)
|
||
if [ "${uncommitted_count:-0}" -gt 0 ]; then
|
||
log "WARNING: $uncommitted_count worktree(s) with uncommitted work"
|
||
while IFS= read -r line; do
|
||
log " UNCOMMITTED: $line"
|
||
# Auto-commit stale work (>60 min without commit)
|
||
local stale=$(echo "$line" | sed 's/.*STALE=\([0-9]*\)m.*/\1/')
|
||
local wt_dir=$(echo "$line" | sed 's/.*DIR=\([^ ]*\) .*/\1/')
|
||
if [ "${stale:-0}" -gt 60 ]; then
|
||
log " AUTO-COMMITTING stale work in $wt_dir (${stale}m stale)"
|
||
(cd "$wt_dir" && git add -A && git commit -m "WIP: orchestrator auto-commit — ${stale}m stale work
|
||
|
||
Preserved by timmy-orchestrator to prevent loss." 2>/dev/null && git push 2>/dev/null) && log " COMMITTED: $wt_dir" || log " COMMIT FAILED: $wt_dir"
|
||
fi
|
||
done < "$state_dir/uncommitted_work.txt"
|
||
fi
|
||
|
||
# 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 via dispatch router..."
|
||
DISPATCH_LOG="$LOG_DIR/dispatch_decisions.log"
|
||
while IFS= read -r line; do
|
||
local repo=$(echo "$line" | sed 's/.*REPO=\([^ ]*\).*//')
|
||
local num=$(echo "$line" | sed 's/.*NUM=\([^ ]*\).*//')
|
||
local title=$(echo "$line" | sed 's/.*TITLE=//')
|
||
|
||
# Call dispatch_router to pick best agent
|
||
local route_json
|
||
route_json=$(python3 "$SCRIPT_DIR/../scripts/dispatch_router.py" "$title" "$repo" 2>/dev/null) || route_json=""
|
||
|
||
local recommended_agent="claude" # fallback
|
||
local route_category="unknown"
|
||
local route_score="0"
|
||
local route_reason="fallback"
|
||
|
||
if [ -n "$route_json" ]; then
|
||
recommended_agent=$(echo "$route_json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('recommended_agent','claude'))" 2>/dev/null || echo "claude")
|
||
route_score=$(echo "$route_json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('score',0))" 2>/dev/null || echo "0")
|
||
route_category=$(echo "$route_json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('category','unknown'))" 2>/dev/null || echo "unknown")
|
||
route_reason=$(echo "$route_json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('reason',''))" 2>/dev/null || echo "")
|
||
fi
|
||
|
||
# Assign via API
|
||
curl -sf -X PATCH "$GITEA_URL/api/v1/repos/$repo/issues/$num" \\
|
||
-H "Authorization: token $GITEA_TOKEN" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d "{\"assignees\":[\"$recommended_agent\"]}" >/dev/null 2>&1 && \\
|
||
log " Assigned #$num ($repo) to $recommended_agent [score=$route_score cat=$route_category]"
|
||
|
||
# Log dispatch decision for audit (RFC3339 timestamp)
|
||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||
"$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$num" "$repo" "$title" "$recommended_agent" "$route_score" "$route_category|$route_reason" \
|
||
>> "$DISPATCH_LOG"
|
||
done < "$state_dir/unassigned.txt"
|
||
else 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"
|
||
|
||
# Start auto-commit-guard daemon for work preservation
|
||
if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then
|
||
nohup bash "$SCRIPT_DIR/auto-commit-guard.sh" 120 >> "$LOG_DIR/auto-commit-guard.log" 2>&1 &
|
||
log "Started auto-commit-guard daemon (PID $!)"
|
||
fi
|
||
|
||
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
|