mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
### Why
When running multiple Claude Code agents in parallel worktrees, they
frequently get stuck: an agent exits and sits at a shell prompt, freezes
mid-task, or waits on an approval prompt with no human watching. Fixing
this currently requires manually checking each tmux window.
### What
Adds a `/orchestrate` skill — a meta-agent supervisor that manages a
fleet of Claude Code agents across tmux windows and spare worktrees. It
auto-discovers available worktrees, spawns agents, monitors them, kicks
idle/stuck ones, auto-approves safe confirmations, and recycles
worktrees on completion.
### How to use
**Prerequisites:**
- One tmux session already running (the skill adds windows to it; it
does not create a new session)
- Spare worktrees on `spare/N` branches (e.g. `AutoGPT3` on `spare/3`,
`AutoGPT7` on `spare/7`)
**Basic workflow:**
```
/orchestrate capacity → see how many spare worktrees are free
/orchestrate start → enter task list, agents spawn automatically
/orchestrate status → check what's running
/orchestrate add → add one more task to the next free worktree
/orchestrate stop → mark inactive (agents finish current work)
/orchestrate poll → one manual poll cycle (debug / on-demand)
```
**Worktree lifecycle:**
```text
spare/N branch → /orchestrate add → new window + feat/branch + claude running
↓
ORCHESTRATOR:DONE
↓
kill window + git checkout spare/N
↓
spare/N (free again)
```
Windows are always capped by worktree count — no creep.
### Changes
- `.claude/skills/orchestrate/SKILL.md` — skill definition with 5
subcommands, state file schema, spawn/recycle helpers, approval policy
- `.claude/skills/orchestrate/scripts/classify-pane.sh` — pane state
classifier: `idle` (shell foreground), `running` (non-shell),
`waiting_approval` (pattern match), `complete` (ORCHESTRATOR:DONE)
- `.claude/skills/orchestrate/scripts/poll-cycle.sh` — poll loop:
reads/updates state file atomically, outputs JSON action list, stuck
detection via output-hash sampling
**State detection:**
| State | Detection method |
|---|---|
| `idle` | `pane_current_command` is a shell (zsh/bash/fish) |
| `running` | `pane_current_command` is non-shell (claude/node) |
| `stuck` | pane hash unchanged for N consecutive polls |
| `waiting_approval` | pattern match on last 40 lines of pane output |
| `complete` | `ORCHESTRATOR:DONE` string present in pane output |
**Safety policy for auto-approvals:** git ops, package installs, tests,
docker compose → approve. `rm -rf` outside worktree, force push, `sudo`,
secrets → escalate to user.
State file lives at `~/.claude/orchestrator-state.json` (outside repo,
never committed).
### Checklist
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `classify-pane.sh`: idle shell → `idle`, running process →
`running`, `ORCHESTRATOR:DONE` → `complete`, approval prompt →
`waiting_approval`, nonexistent window → `error`
- [x] `poll-cycle.sh`: inactive state → `[]`, empty agents array → `[]`,
spare worktree discovery, stuck detection (3-poll hash cycle)
- [x] Real agent spawn in `autogpt1` tmux session — agent ran, output
`ORCHESTRATOR:DONE`, recycle verified
- [x] Upfront JSON validation before `set -e`-guarded jq reads
- [x] Idle timer reset only on `idle → running` transition (not stuck),
preventing false stuck-detections
- [x] Classify fallback only triggers when output is empty (no
double-JSON on classify exit 1)
165 lines
6.7 KiB
Bash
Executable File
165 lines
6.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# run-loop.sh — Mechanical babysitter for the agent fleet (runs in its own tmux window)
|
||
#
|
||
# Handles ONLY two things that need no intelligence:
|
||
# idle → restart claude using --resume SESSION_ID (or --continue) to restore context
|
||
# approve → auto-approve safe dialogs, press Enter on numbered-option dialogs
|
||
#
|
||
# Everything else — ORCHESTRATOR:DONE, verification, /pr-test, final evaluation,
|
||
# marking done, deciding to close windows — is the orchestrating Claude's job.
|
||
# poll-cycle.sh sets state to pending_evaluation when ORCHESTRATOR:DONE is detected;
|
||
# the orchestrator's background poll loop handles it from there.
|
||
#
|
||
# Usage: run-loop.sh
|
||
# Env: POLL_INTERVAL (default: 30), ORCHESTRATOR_STATE_FILE
|
||
|
||
set -euo pipefail
|
||
|
||
# Copy scripts to a stable location outside the repo so they survive branch
|
||
# checkouts (e.g. recycle-agent.sh switching spare/N back into this worktree
|
||
# would wipe .claude/skills/orchestrate/scripts if the skill only exists on the
|
||
# current branch).
|
||
_ORIGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
STABLE_SCRIPTS_DIR="$HOME/.claude/orchestrator/scripts"
|
||
mkdir -p "$STABLE_SCRIPTS_DIR"
|
||
cp "$_ORIGIN_DIR"/*.sh "$STABLE_SCRIPTS_DIR/"
|
||
chmod +x "$STABLE_SCRIPTS_DIR"/*.sh
|
||
SCRIPTS_DIR="$STABLE_SCRIPTS_DIR"
|
||
|
||
STATE_FILE="${ORCHESTRATOR_STATE_FILE:-$HOME/.claude/orchestrator-state.json}"
|
||
POLL_INTERVAL="${POLL_INTERVAL:-30}"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# update_state WINDOW FIELD VALUE
|
||
# ---------------------------------------------------------------------------
|
||
update_state() {
|
||
local window="$1" field="$2" value="$3"
|
||
jq --arg w "$window" --arg f "$field" --arg v "$value" \
|
||
'.agents |= map(if .window == $w then .[$f] = $v else . end)' \
|
||
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||
}
|
||
|
||
update_state_int() {
|
||
local window="$1" field="$2" value="$3"
|
||
jq --arg w "$window" --arg f "$field" --argjson v "$value" \
|
||
'.agents |= map(if .window == $w then .[$f] = $v else . end)' \
|
||
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
||
}
|
||
|
||
agent_field() {
|
||
jq -r --arg w "$1" --arg f "$2" \
|
||
'.agents[] | select(.window == $w) | .[$f] // ""' \
|
||
"$STATE_FILE" 2>/dev/null
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# wait_for_prompt WINDOW — wait up to 60s for Claude's ❯ prompt
|
||
# ---------------------------------------------------------------------------
|
||
wait_for_prompt() {
|
||
local window="$1"
|
||
for i in $(seq 1 60); do
|
||
local cmd pane
|
||
cmd=$(tmux display-message -t "$window" -p '#{pane_current_command}' 2>/dev/null || echo "")
|
||
pane=$(tmux capture-pane -t "$window" -p 2>/dev/null || echo "")
|
||
if echo "$pane" | grep -q "Enter to confirm"; then
|
||
tmux send-keys -t "$window" Down Enter; sleep 2; continue
|
||
fi
|
||
[[ "$cmd" == "node" ]] && echo "$pane" | grep -q "❯" && return 0
|
||
sleep 1
|
||
done
|
||
return 1 # timed out
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# handle_kick WINDOW STATE — only for idle (crashed) agents, not stuck
|
||
# ---------------------------------------------------------------------------
|
||
handle_kick() {
|
||
local window="$1" state="$2"
|
||
[[ "$state" != "idle" ]] && return # stuck agents handled by supervisor
|
||
|
||
local worktree_path session_id
|
||
worktree_path=$(agent_field "$window" "worktree_path")
|
||
session_id=$(agent_field "$window" "session_id")
|
||
|
||
echo "[$(date +%H:%M:%S)] KICK restart $window — agent exited, resuming session"
|
||
|
||
# Resume the exact session so the agent retains full context — no need to re-send objective
|
||
if [ -n "$session_id" ]; then
|
||
tmux send-keys -t "$window" "cd '${worktree_path}' && claude --resume '${session_id}' --permission-mode bypassPermissions" Enter
|
||
else
|
||
tmux send-keys -t "$window" "cd '${worktree_path}' && claude --continue --permission-mode bypassPermissions" Enter
|
||
fi
|
||
|
||
wait_for_prompt "$window" || echo "[$(date +%H:%M:%S)] KICK WARNING $window — timed out waiting for ❯"
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# handle_approve WINDOW — auto-approve dialogs that need no judgment
|
||
# ---------------------------------------------------------------------------
|
||
handle_approve() {
|
||
local window="$1"
|
||
local pane_tail
|
||
pane_tail=$(tmux capture-pane -t "$window" -p 2>/dev/null | tail -3 || echo "")
|
||
|
||
# Settings error dialog at startup
|
||
if echo "$pane_tail" | grep -q "Enter to confirm"; then
|
||
echo "[$(date +%H:%M:%S)] APPROVE dialog $window — settings error"
|
||
tmux send-keys -t "$window" Down Enter
|
||
return
|
||
fi
|
||
|
||
# Numbered-option dialog (e.g. "Do you want to make this edit?")
|
||
# ❯ is already on option 1 (Yes) — Enter confirms it
|
||
if echo "$pane_tail" | grep -qE "❯\s*1\." || echo "$pane_tail" | grep -q "Esc to cancel"; then
|
||
echo "[$(date +%H:%M:%S)] APPROVE edit $window"
|
||
tmux send-keys -t "$window" "" Enter
|
||
return
|
||
fi
|
||
|
||
# y/n prompt for safe operations
|
||
if echo "$pane_tail" | grep -qiE "(^git |^npm |^pnpm |^poetry |^pytest|^docker |^make |^cargo |^pip |^yarn |curl .*(localhost|127\.0\.0\.1))"; then
|
||
echo "[$(date +%H:%M:%S)] APPROVE safe $window"
|
||
tmux send-keys -t "$window" "y" Enter
|
||
return
|
||
fi
|
||
|
||
# Anything else — supervisor handles it, just log
|
||
echo "[$(date +%H:%M:%S)] APPROVE skip $window — unknown dialog, supervisor will handle"
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main loop
|
||
# ---------------------------------------------------------------------------
|
||
echo "[$(date +%H:%M:%S)] run-loop started (mechanical only, poll every ${POLL_INTERVAL}s)"
|
||
echo "[$(date +%H:%M:%S)] Supervisor: orchestrating Claude session (not a separate window)"
|
||
echo "---"
|
||
|
||
while true; do
|
||
if ! jq -e '.active == true' "$STATE_FILE" >/dev/null 2>&1; then
|
||
echo "[$(date +%H:%M:%S)] active=false — exiting."
|
||
exit 0
|
||
fi
|
||
|
||
ACTIONS=$("$SCRIPTS_DIR/poll-cycle.sh" 2>/dev/null || echo "[]")
|
||
KICKED=0; DONE=0
|
||
|
||
while IFS= read -r action; do
|
||
[ -z "$action" ] && continue
|
||
WINDOW=$(echo "$action" | jq -r '.window // ""')
|
||
ACTION=$(echo "$action" | jq -r '.action // ""')
|
||
STATE=$(echo "$action" | jq -r '.state // ""')
|
||
|
||
case "$ACTION" in
|
||
kick) handle_kick "$WINDOW" "$STATE" || true; KICKED=$(( KICKED + 1 )) ;;
|
||
approve) handle_approve "$WINDOW" || true ;;
|
||
complete) DONE=$(( DONE + 1 )) ;; # poll-cycle already set state=pending_evaluation; orchestrator handles
|
||
esac
|
||
done < <(echo "$ACTIONS" | jq -c '.[]' 2>/dev/null || true)
|
||
|
||
RUNNING=$(jq '[.agents[] | select(.state | test("running|stuck|waiting_approval|idle"))] | length' \
|
||
"$STATE_FILE" 2>/dev/null || echo 0)
|
||
|
||
echo "[$(date +%H:%M:%S)] Poll — ${RUNNING} running ${KICKED} kicked ${DONE} recycled"
|
||
sleep "$POLL_INTERVAL"
|
||
done
|