|
#!/bin/bash |
|
set -e |
|
|
|
# ============================================ |
|
# Task Orchestrator |
|
# Parallel task executor for Claude Code |
|
# |
|
# Spawns multiple Claude Code instances in iTerm2 windows, |
|
# each working on a separate task from a directory-based queue. |
|
# |
|
# Features: |
|
# - Directory-based task queue (pending → wip → completed/failed) |
|
# - Atomic task claiming with mkdir locks (macOS compatible) |
|
# - Worker pool pattern with configurable parallelism |
|
# - Graceful shutdown on Ctrl+C |
|
# - Auto-respawn workers when tasks complete |
|
# |
|
# Requirements: macOS, iTerm2, jq, Claude Code CLI |
|
# Compatible with bash 3.2+ (macOS default) |
|
# |
|
# By Hila Shmuel @hilashmuel |
|
# MIT License - https://opensource.org/licenses/MIT |
|
# ============================================ |
|
|
|
# Colors for output |
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[1;33m' |
|
BLUE='\033[0;34m' |
|
PURPLE='\033[0;35m' |
|
CYAN='\033[0;36m' |
|
WHITE='\033[1;37m' |
|
BOLD='\033[1m' |
|
NC='\033[0m' # No Color |
|
|
|
# Configuration (can be overridden via CLI) |
|
TASKS_DIR="${TASKS_DIR:-./tasks}" |
|
WORKING_DIR="${WORKING_DIR:-$(pwd)}" |
|
MAX_PARALLEL=${MAX_PARALLEL:-10} |
|
VERBOSE=${VERBOSE:-false} |
|
|
|
# Timestamp for this run |
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S") |
|
|
|
# ============================================ |
|
# Helper Functions |
|
# ============================================ |
|
|
|
print_banner() { |
|
echo -e "${CYAN}" |
|
echo "+=================================================================+" |
|
echo "| |" |
|
echo "| Task Orchestrator |" |
|
echo "| Parallel Claude Code Task Runner |" |
|
echo "| |" |
|
echo "+=================================================================+" |
|
echo -e "${NC}" |
|
} |
|
|
|
print_separator() { |
|
echo -e "${BLUE}------------------------------------------------------------------${NC}" |
|
} |
|
|
|
log_info() { |
|
local message="$1" |
|
local timestamp=$(date +"%Y-%m-%d %H:%M:%S") |
|
echo -e "${WHITE}[$timestamp]${NC} ${BLUE}INFO:${NC} $message" |
|
[ -f "$LOG_FILE" ] && echo "[$timestamp] INFO: $message" >> "$LOG_FILE" |
|
return 0 |
|
} |
|
|
|
log_success() { |
|
local message="$1" |
|
local timestamp=$(date +"%Y-%m-%d %H:%M:%S") |
|
echo -e "${WHITE}[$timestamp]${NC} ${GREEN}SUCCESS:${NC} $message" |
|
[ -f "$LOG_FILE" ] && echo "[$timestamp] SUCCESS: $message" >> "$LOG_FILE" |
|
return 0 |
|
} |
|
|
|
log_warning() { |
|
local message="$1" |
|
local timestamp=$(date +"%Y-%m-%d %H:%M:%S") |
|
echo -e "${WHITE}[$timestamp]${NC} ${YELLOW}WARNING:${NC} $message" |
|
[ -f "$LOG_FILE" ] && echo "[$timestamp] WARNING: $message" >> "$LOG_FILE" |
|
return 0 |
|
} |
|
|
|
log_error() { |
|
local message="$1" |
|
local timestamp=$(date +"%Y-%m-%d %H:%M:%S") |
|
echo -e "${WHITE}[$timestamp]${NC} ${RED}ERROR:${NC} $message" |
|
[ -f "$LOG_FILE" ] && echo "[$timestamp] ERROR: $message" >> "$LOG_FILE" |
|
return 0 |
|
} |
|
|
|
log_worker() { |
|
local worker="$1" |
|
local message="$2" |
|
local timestamp=$(date +"%Y-%m-%d %H:%M:%S") |
|
echo -e "${WHITE}[$timestamp]${NC} ${PURPLE}WORKER $worker:${NC} $message" |
|
[ -f "$LOG_FILE" ] && echo "[$timestamp] WORKER $worker: $message" >> "$LOG_FILE" |
|
return 0 |
|
} |
|
|
|
verbose_say() { |
|
if [ "$VERBOSE" = true ]; then |
|
say "$1" |
|
fi |
|
} |
|
|
|
# ============================================ |
|
# Directory Management |
|
# ============================================ |
|
|
|
init_task_dirs() { |
|
mkdir -p "$TASKS_DIR/pending" |
|
mkdir -p "$TASKS_DIR/wip" |
|
mkdir -p "$TASKS_DIR/completed" |
|
mkdir -p "$TASKS_DIR/failed" |
|
mkdir -p "$TASKS_DIR/locks" |
|
mkdir -p "$TASKS_DIR/logs" |
|
log_success "Initialized task directories at $TASKS_DIR" |
|
} |
|
|
|
ensure_task_dirs() { |
|
if [ ! -d "$TASKS_DIR/pending" ]; then |
|
log_error "Task directories not found. Run with --init first." |
|
exit 1 |
|
fi |
|
} |
|
|
|
# ============================================ |
|
# Task Discovery (Directory-based, FIFO order) |
|
# ============================================ |
|
|
|
get_pending_tasks() { |
|
# Returns list of pending task files sorted by modification time (FIFO) |
|
find "$TASKS_DIR/pending" -name "*.json" -type f 2>/dev/null | \ |
|
xargs ls -t 2>/dev/null | \ |
|
tail -r 2>/dev/null || \ |
|
find "$TASKS_DIR/pending" -name "*.json" -type f 2>/dev/null | sort |
|
} |
|
|
|
get_pending_count() { |
|
find "$TASKS_DIR/pending" -name "*.json" -type f 2>/dev/null | wc -l | tr -d ' ' |
|
} |
|
|
|
get_wip_count() { |
|
find "$TASKS_DIR/wip" -name "*.json" -type f 2>/dev/null | wc -l | tr -d ' ' |
|
} |
|
|
|
get_completed_count() { |
|
find "$TASKS_DIR/completed" -name "*.json" -type f 2>/dev/null | wc -l | tr -d ' ' |
|
} |
|
|
|
get_failed_count() { |
|
find "$TASKS_DIR/failed" -name "*.json" -type f 2>/dev/null | wc -l | tr -d ' ' |
|
} |
|
|
|
# ============================================ |
|
# Task Claiming with mkdir atomic lock (macOS compatible) |
|
# ============================================ |
|
|
|
claim_task() { |
|
local task_path="$1" |
|
local task_name=$(basename "$task_path") |
|
local lock_dir="$TASKS_DIR/locks/${task_name}.lock" |
|
local wip_path="$TASKS_DIR/wip/$task_name" |
|
|
|
# Try to acquire lock using mkdir (atomic on all systems) |
|
if ! mkdir "$lock_dir" 2>/dev/null; then |
|
# Another process has the lock |
|
return 1 |
|
fi |
|
|
|
# We have the lock - cleanup on exit |
|
trap "rmdir '$lock_dir' 2>/dev/null" EXIT |
|
|
|
# Double-check file still exists in pending |
|
if [ ! -f "$task_path" ]; then |
|
rmdir "$lock_dir" 2>/dev/null |
|
return 1 |
|
fi |
|
|
|
# Move to wip |
|
if ! mv "$task_path" "$wip_path" 2>/dev/null; then |
|
rmdir "$lock_dir" 2>/dev/null |
|
return 1 |
|
fi |
|
|
|
# Update task with started_at |
|
local tmp_file=$(mktemp) |
|
if jq --arg ts "$(date -Iseconds)" \ |
|
'.started_at = $ts | .status = "in_progress"' \ |
|
"$wip_path" > "$tmp_file" 2>/dev/null; then |
|
mv "$tmp_file" "$wip_path" |
|
fi |
|
|
|
# Release lock |
|
rmdir "$lock_dir" 2>/dev/null |
|
trap - EXIT |
|
|
|
echo "$wip_path" |
|
} |
|
|
|
complete_task() { |
|
local wip_path="$1" |
|
local task_name=$(basename "$wip_path") |
|
local completed_path="$TASKS_DIR/completed/$task_name" |
|
|
|
# Update task with completion info |
|
local tmp_file=$(mktemp) |
|
jq --arg ts "$(date -Iseconds)" \ |
|
'.completed_at = $ts | .status = "completed"' \ |
|
"$wip_path" > "$tmp_file" && mv "$tmp_file" "$wip_path" |
|
|
|
mv "$wip_path" "$completed_path" 2>/dev/null |
|
} |
|
|
|
fail_task() { |
|
local wip_path="$1" |
|
local task_name=$(basename "$wip_path") |
|
local failed_path="$TASKS_DIR/failed/$task_name" |
|
|
|
# Update task with failure info |
|
local tmp_file=$(mktemp) |
|
jq --arg ts "$(date -Iseconds)" \ |
|
'.completed_at = $ts | .status = "failed"' \ |
|
"$wip_path" > "$tmp_file" && mv "$tmp_file" "$wip_path" |
|
|
|
mv "$wip_path" "$failed_path" 2>/dev/null |
|
} |
|
|
|
# ============================================ |
|
# Open iTerm2 Window for Worker |
|
# ============================================ |
|
|
|
open_worker_window() { |
|
local worker_id="$1" |
|
local script_path="$2" |
|
local window_name="$3" |
|
|
|
# Sanitize window name for AppleScript |
|
local safe_name=$(echo "$window_name" | sed 's/[\/()]/-/g; s/--*/-/g; s/^-//; s/-$//') |
|
|
|
osascript <<EOF |
|
tell application "iTerm2" |
|
activate |
|
set newWindow to (create window with default profile) |
|
tell current session of newWindow |
|
set name to "$safe_name" |
|
write text "source $script_path" |
|
end tell |
|
end tell |
|
EOF |
|
} |
|
|
|
# ============================================ |
|
# Generate Worker Prompt |
|
# ============================================ |
|
|
|
generate_worker_prompt() { |
|
local task_file="$1" |
|
local worker_id="$2" |
|
|
|
# Read task JSON |
|
local task_name=$(jq -r '.task_name // "Unnamed Task"' "$task_file") |
|
local task_description=$(jq -r '.task_description // ""' "$task_file") |
|
local category=$(jq -r '.category // "General"' "$task_file") |
|
|
|
# Check if custom prompt exists |
|
local custom_prompt=$(jq -r '.prompt // ""' "$task_file") |
|
|
|
if [ -n "$custom_prompt" ] && [ "$custom_prompt" != "null" ]; then |
|
echo "$custom_prompt" |
|
return |
|
fi |
|
|
|
# Generate default prompt from task fields |
|
cat <<PROMPT |
|
You are WORKER $worker_id executing a task. |
|
|
|
## Task: $task_name |
|
**Category:** $category |
|
|
|
## Description |
|
$task_description |
|
|
|
## Instructions |
|
1. Read and understand the task description above |
|
2. Implement the required changes |
|
3. Test your changes if applicable |
|
4. Commit your changes with a descriptive message |
|
|
|
## When Complete |
|
Output this marker when done: |
|
<worker-$worker_id-complete>DONE</worker-$worker_id-complete> |
|
|
|
REMEMBER: You are WORKER $worker_id working on "$task_name" |
|
PROMPT |
|
} |
|
|
|
# ============================================ |
|
# Spawn a Single Worker |
|
# ============================================ |
|
|
|
spawn_worker() { |
|
local worker_slot="$1" |
|
local task_path="$2" |
|
local global_worker_id="$3" |
|
|
|
# Claim the task |
|
local wip_path=$(claim_task "$task_path") |
|
if [ -z "$wip_path" ]; then |
|
log_warning "Failed to claim task: $(basename "$task_path")" |
|
return 1 |
|
fi |
|
|
|
# Update task with worker_id |
|
local tmp_file=$(mktemp) |
|
jq --arg wid "$global_worker_id" '.worker_id = $wid' "$wip_path" > "$tmp_file" && mv "$tmp_file" "$wip_path" |
|
|
|
local task_name=$(jq -r '.task_name // "Unnamed"' "$wip_path") |
|
local category=$(jq -r '.category // "General"' "$wip_path") |
|
|
|
echo -e " ${PURPLE}Slot $worker_slot (Worker #$global_worker_id):${NC} $task_name" |
|
echo -e " ${BLUE}Category:${NC} $category" |
|
echo "" |
|
|
|
# Create marker file for tracking |
|
local marker_file="$TASKS_DIR/locks/.worker_slot_${worker_slot}_running_$TIMESTAMP" |
|
echo "$wip_path" > "$marker_file" |
|
|
|
# Generate prompt |
|
local prompt=$(generate_worker_prompt "$wip_path" "$global_worker_id") |
|
local prompt_file="$TASKS_DIR/logs/prompt_worker_${global_worker_id}.txt" |
|
echo "$prompt" > "$prompt_file" |
|
|
|
# Worker script |
|
local worker_script="$TASKS_DIR/logs/worker_${global_worker_id}.sh" |
|
|
|
log_worker "$worker_slot" "Starting: $task_name" |
|
|
|
# Write worker script |
|
cat > "$worker_script" << WORKER_EOF |
|
#!/bin/bash |
|
cd "$WORKING_DIR" |
|
echo "Starting Claude for: $task_name" |
|
echo "================================================" |
|
echo "Category: $category" |
|
echo "Worker: $global_worker_id" |
|
echo "================================================" |
|
|
|
# Marker file for completion detection |
|
DONE_MARKER="$TASKS_DIR/logs/.done_worker_${global_worker_id}" |
|
rm -f "\$DONE_MARKER" |
|
|
|
# Create hook settings for Stop detection |
|
HOOK_SETTINGS="$TASKS_DIR/logs/hooks_worker_${global_worker_id}.json" |
|
cat > "\$HOOK_SETTINGS" << HOOKEOF |
|
{ |
|
"hooks": { |
|
"Stop": [{ |
|
"hooks": [{ |
|
"type": "command", |
|
"command": "touch \$DONE_MARKER" |
|
}] |
|
}] |
|
} |
|
} |
|
HOOKEOF |
|
|
|
# Read prompt |
|
PROMPT=\$(cat "$prompt_file") |
|
|
|
# Background monitor for completion |
|
( |
|
while [ ! -f "\$DONE_MARKER" ]; do |
|
sleep 2 |
|
done |
|
echo "" |
|
echo "Claude finished. Closing in 5 seconds..." |
|
sleep 5 |
|
pkill -f "claude.*hooks_worker_${global_worker_id}" 2>/dev/null |
|
) & |
|
MONITOR_PID=\$! |
|
|
|
# Run Claude |
|
claude --dangerously-skip-permissions --settings "\$HOOK_SETTINGS" "\$PROMPT" |
|
|
|
# Cleanup monitor |
|
kill \$MONITOR_PID 2>/dev/null |
|
|
|
echo "" |
|
echo "================================================" |
|
|
|
# Handle completion |
|
WIP_PATH="$wip_path" |
|
TASKS_DIR="$TASKS_DIR" |
|
|
|
if [ -f "\$DONE_MARKER" ]; then |
|
echo "Worker $global_worker_id COMPLETED: $task_name" |
|
|
|
# Move to completed |
|
TASK_NAME=\$(basename "\$WIP_PATH") |
|
TMP_FILE=\$(mktemp) |
|
jq --arg ts "\$(date -Iseconds)" '.completed_at = \$ts | .status = "completed"' "\$WIP_PATH" > "\$TMP_FILE" && mv "\$TMP_FILE" "\$WIP_PATH" |
|
mv "\$WIP_PATH" "\$TASKS_DIR/completed/\$TASK_NAME" 2>/dev/null && echo "Task marked as completed" |
|
else |
|
echo "Worker $global_worker_id FAILED: $task_name" |
|
|
|
# Move to failed |
|
TASK_NAME=\$(basename "\$WIP_PATH") |
|
TMP_FILE=\$(mktemp) |
|
jq --arg ts "\$(date -Iseconds)" '.completed_at = \$ts | .status = "failed"' "\$WIP_PATH" > "\$TMP_FILE" && mv "\$TMP_FILE" "\$WIP_PATH" |
|
mv "\$WIP_PATH" "\$TASKS_DIR/failed/\$TASK_NAME" 2>/dev/null && echo "Task marked as failed" |
|
fi |
|
|
|
# Cleanup |
|
rm -f "\$DONE_MARKER" "\$HOOK_SETTINGS" "$prompt_file" "$marker_file" |
|
echo "Window will close in 5 seconds..." |
|
sleep 5 |
|
exit 0 |
|
WORKER_EOF |
|
|
|
chmod +x "$worker_script" |
|
|
|
open_worker_window "$global_worker_id" "$worker_script" "Worker $global_worker_id - $task_name" |
|
} |
|
|
|
# ============================================ |
|
# Show Task Summary |
|
# ============================================ |
|
|
|
show_task_summary() { |
|
echo "" |
|
echo -e "${BOLD}${CYAN}Task Summary:${NC}" |
|
|
|
local pending=$(get_pending_count) |
|
local wip=$(get_wip_count) |
|
local completed=$(get_completed_count) |
|
local failed=$(get_failed_count) |
|
|
|
echo -e " ${YELLOW}Pending:${NC} $pending" |
|
echo -e " ${PURPLE}In Progress:${NC} $wip" |
|
echo -e " ${GREEN}Completed:${NC} $completed" |
|
echo -e " ${RED}Failed:${NC} $failed" |
|
echo "" |
|
|
|
# Show pending tasks |
|
if [ "$pending" -gt 0 ]; then |
|
echo -e "${BOLD}${CYAN}Pending Tasks:${NC}" |
|
for f in "$TASKS_DIR/pending"/*.json; do |
|
if [ -f "$f" ]; then |
|
local name=$(jq -r '.task_name // "Unnamed"' "$f") |
|
local cat=$(jq -r '.category // "-"' "$f") |
|
echo -e " - ${WHITE}$name${NC} ($cat)" |
|
fi |
|
done |
|
echo "" |
|
fi |
|
} |
|
|
|
# ============================================ |
|
# Reset Functions |
|
# ============================================ |
|
|
|
reset_failed() { |
|
echo "Resetting failed tasks to pending..." |
|
local count=0 |
|
for f in "$TASKS_DIR/failed"/*.json; do |
|
if [ -f "$f" ]; then |
|
local name=$(basename "$f") |
|
# Remove status fields |
|
local tmp_file=$(mktemp) |
|
jq 'del(.status, .started_at, .completed_at, .worker_id)' "$f" > "$tmp_file" && mv "$tmp_file" "$f" |
|
mv "$f" "$TASKS_DIR/pending/$name" |
|
echo " Reset: $name" |
|
count=$((count + 1)) |
|
fi |
|
done |
|
echo "Reset $count task(s)." |
|
} |
|
|
|
reset_wip() { |
|
echo "Resetting WIP tasks to pending..." |
|
local count=0 |
|
for f in "$TASKS_DIR/wip"/*.json; do |
|
if [ -f "$f" ]; then |
|
local name=$(basename "$f") |
|
# Remove status fields |
|
local tmp_file=$(mktemp) |
|
jq 'del(.status, .started_at, .completed_at, .worker_id)' "$f" > "$tmp_file" && mv "$tmp_file" "$f" |
|
mv "$f" "$TASKS_DIR/pending/$name" |
|
echo " Reset: $name" |
|
count=$((count + 1)) |
|
fi |
|
done |
|
echo "Reset $count task(s)." |
|
} |
|
|
|
# ============================================ |
|
# Main Execution - Worker Pool Pattern |
|
# ============================================ |
|
|
|
main() { |
|
# Ensure we're using absolute paths |
|
TASKS_DIR=$(cd "$TASKS_DIR" 2>/dev/null && pwd || echo "$TASKS_DIR") |
|
WORKING_DIR=$(cd "$WORKING_DIR" 2>/dev/null && pwd || echo "$WORKING_DIR") |
|
|
|
# Set up log file |
|
LOG_FILE="$TASKS_DIR/logs/orchestrator_$TIMESTAMP.log" |
|
mkdir -p "$TASKS_DIR/logs" |
|
|
|
# Clean up old temp files |
|
rm -f "$TASKS_DIR/locks"/.worker_slot_*_running_* |
|
rm -f "$TASKS_DIR/logs"/worker_*.sh |
|
rm -f "$TASKS_DIR/logs"/prompt_worker_*.txt |
|
rm -f "$TASKS_DIR/logs"/hooks_worker_*.json |
|
rm -f "$TASKS_DIR/logs"/.done_worker_* |
|
|
|
print_banner |
|
|
|
log_info "Starting Task Orchestrator" |
|
log_info "Tasks directory: $TASKS_DIR" |
|
log_info "Working directory: $WORKING_DIR" |
|
log_info "Worker pool size: $MAX_PARALLEL" |
|
verbose_say "Starting task orchestrator with $MAX_PARALLEL workers" |
|
|
|
print_separator |
|
show_task_summary |
|
print_separator |
|
|
|
# Check for pending tasks |
|
TOTAL_TASKS=$(get_pending_count) |
|
|
|
if [ "$TOTAL_TASKS" -eq 0 ]; then |
|
log_success "No pending tasks to process!" |
|
exit 0 |
|
fi |
|
|
|
log_info "Found $TOTAL_TASKS pending task(s)" |
|
verbose_say "Processing $TOTAL_TASKS tasks" |
|
|
|
print_separator |
|
echo "" |
|
echo -e "${BOLD}${CYAN}WORKER POOL STATUS:${NC}" |
|
echo -e " Pending Tasks: $TOTAL_TASKS" |
|
echo -e " Pool Size: $MAX_PARALLEL" |
|
print_separator |
|
|
|
# State tracking |
|
local global_worker_counter=0 |
|
local tasks_completed=0 |
|
local tasks_failed=0 |
|
|
|
# Graceful shutdown |
|
STOP_SPAWNING=false |
|
trap 'STOP_SPAWNING=true; echo ""; log_warning "Ctrl+C - stopping new tasks"; verbose_say "Stopping new tasks"' SIGINT |
|
|
|
# Slot tracking |
|
local SLOT_DIR="$TASKS_DIR/locks/slots_$TIMESTAMP" |
|
mkdir -p "$SLOT_DIR" |
|
|
|
# Initial spawn |
|
echo "" |
|
echo -e "${BOLD}${YELLOW}Starting workers...${NC}" |
|
echo "" |
|
|
|
local initial_workers=$MAX_PARALLEL |
|
if [ "$initial_workers" -gt "$TOTAL_TASKS" ]; then |
|
initial_workers=$TOTAL_TASKS |
|
fi |
|
|
|
local slot=1 |
|
for task_path in $(get_pending_tasks | head -$initial_workers); do |
|
if [ -f "$task_path" ]; then |
|
global_worker_counter=$((global_worker_counter + 1)) |
|
spawn_worker "$slot" "$task_path" "$global_worker_counter" |
|
echo "$task_path" > "$SLOT_DIR/slot_${slot}_task" |
|
slot=$((slot + 1)) |
|
|
|
# Delay between spawns |
|
if [ $slot -le $initial_workers ]; then |
|
sleep 5 |
|
fi |
|
fi |
|
done |
|
|
|
print_separator |
|
echo "" |
|
echo -e "${BOLD}${YELLOW}Worker pool active! Monitoring...${NC}" |
|
echo "" |
|
verbose_say "Worker pool active" |
|
|
|
# Monitor loop |
|
local check_count=0 |
|
local max_check_time=14400 # 4 hours |
|
local check_interval=5 |
|
|
|
while true; do |
|
sleep $check_interval |
|
check_count=$((check_count + 1)) |
|
|
|
local active_workers=0 |
|
local slots_to_respawn="" |
|
|
|
# Check each slot |
|
for ((s=1; s<=MAX_PARALLEL; s++)); do |
|
local marker_file="$TASKS_DIR/locks/.worker_slot_${s}_running_$TIMESTAMP" |
|
local slot_task_file="$SLOT_DIR/slot_${s}_task" |
|
|
|
if [ -f "$marker_file" ]; then |
|
active_workers=$((active_workers + 1)) |
|
elif [ -f "$slot_task_file" ]; then |
|
# Worker finished |
|
local original_task=$(cat "$slot_task_file") |
|
local task_basename=$(basename "$original_task") |
|
|
|
if [ -f "$TASKS_DIR/completed/$task_basename" ]; then |
|
log_success "Slot $s completed: $task_basename" |
|
tasks_completed=$((tasks_completed + 1)) |
|
elif [ -f "$TASKS_DIR/failed/$task_basename" ]; then |
|
log_warning "Slot $s failed: $task_basename" |
|
tasks_failed=$((tasks_failed + 1)) |
|
elif [ -f "$TASKS_DIR/wip/$task_basename" ]; then |
|
# Worker crashed |
|
log_warning "Slot $s crashed: $task_basename" |
|
fail_task "$TASKS_DIR/wip/$task_basename" |
|
tasks_failed=$((tasks_failed + 1)) |
|
fi |
|
|
|
rm -f "$slot_task_file" |
|
|
|
# Queue for respawn |
|
if [ "$(get_pending_count)" -gt 0 ]; then |
|
slots_to_respawn="$slots_to_respawn $s" |
|
fi |
|
fi |
|
done |
|
|
|
# Respawn workers |
|
if [ "$STOP_SPAWNING" = false ]; then |
|
for slot in $slots_to_respawn; do |
|
local next_task=$(get_pending_tasks | head -1) |
|
if [ -n "$next_task" ] && [ -f "$next_task" ]; then |
|
echo "" |
|
log_info "Respawning worker in slot $slot" |
|
global_worker_counter=$((global_worker_counter + 1)) |
|
spawn_worker "$slot" "$next_task" "$global_worker_counter" |
|
echo "$next_task" > "$SLOT_DIR/slot_${slot}_task" |
|
active_workers=$((active_workers + 1)) |
|
sleep 5 |
|
fi |
|
done |
|
fi |
|
|
|
# Progress update every 30 seconds |
|
if [ $((check_count % 6)) -eq 0 ]; then |
|
local elapsed=$((check_count * check_interval)) |
|
local remaining=$(get_pending_count) |
|
echo -e "${WHITE}[$(date +"%H:%M:%S")]${NC} ${CYAN}Progress:${NC} $tasks_completed completed, $tasks_failed failed, $remaining pending, $active_workers active" |
|
fi |
|
|
|
# Check if done |
|
local remaining=$(get_pending_count) |
|
local wip=$(get_wip_count) |
|
|
|
if [ $active_workers -eq 0 ]; then |
|
if [ "$STOP_SPAWNING" = true ]; then |
|
log_info "Graceful shutdown complete" |
|
break |
|
elif [ "$remaining" -eq 0 ] && [ "$wip" -eq 0 ]; then |
|
log_info "All tasks processed" |
|
break |
|
fi |
|
fi |
|
|
|
# Timeout |
|
local elapsed_time=$((check_count * check_interval)) |
|
if [ $elapsed_time -ge $max_check_time ]; then |
|
log_warning "Timeout reached" |
|
break |
|
fi |
|
done |
|
|
|
# Cleanup |
|
rm -rf "$SLOT_DIR" |
|
trap - SIGINT |
|
|
|
# Final summary |
|
local total_time=$((check_count * check_interval)) |
|
local minutes=$((total_time / 60)) |
|
local seconds=$((total_time % 60)) |
|
|
|
print_separator |
|
echo "" |
|
if [ "$STOP_SPAWNING" = true ]; then |
|
echo -e "${YELLOW}${BOLD}GRACEFUL SHUTDOWN COMPLETE${NC}" |
|
else |
|
echo -e "${GREEN}${BOLD}ALL TASKS PROCESSED${NC}" |
|
fi |
|
|
|
print_separator |
|
show_task_summary |
|
|
|
echo -e " ${GREEN}Succeeded:${NC} $tasks_completed" |
|
echo -e " ${RED}Failed:${NC} $tasks_failed" |
|
echo -e " ${PURPLE}Workers Spawned:${NC} $global_worker_counter" |
|
echo -e " ${BLUE}Log File:${NC} $LOG_FILE" |
|
echo -e " ${YELLOW}Time:${NC} ${minutes}m ${seconds}s" |
|
print_separator |
|
|
|
verbose_say "Orchestrator finished. $tasks_completed succeeded, $tasks_failed failed." |
|
log_success "Task orchestrator finished" |
|
} |
|
|
|
# ============================================ |
|
# CLI Argument Parsing |
|
# ============================================ |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case $1 in |
|
--tasks-dir|-t) |
|
TASKS_DIR="$2" |
|
shift 2 |
|
;; |
|
--working-dir|-w) |
|
WORKING_DIR="$2" |
|
shift 2 |
|
;; |
|
--max-parallel|-p) |
|
MAX_PARALLEL="$2" |
|
shift 2 |
|
;; |
|
--verbose|-v) |
|
VERBOSE=true |
|
shift |
|
;; |
|
--init) |
|
init_task_dirs |
|
exit 0 |
|
;; |
|
--status|-s) |
|
print_banner |
|
ensure_task_dirs |
|
show_task_summary |
|
exit 0 |
|
;; |
|
--reset-failed|-r) |
|
ensure_task_dirs |
|
reset_failed |
|
exit 0 |
|
;; |
|
--reset-wip) |
|
ensure_task_dirs |
|
reset_wip |
|
exit 0 |
|
;; |
|
--help|-h) |
|
echo -e "${CYAN}${BOLD}Task Orchestrator${NC}" |
|
echo "Parallel Claude Code task runner" |
|
echo "" |
|
echo "Usage: $0 [options]" |
|
echo "" |
|
echo "Options:" |
|
echo " -t, --tasks-dir PATH Tasks directory (default: ./tasks)" |
|
echo " -w, --working-dir PATH Working directory for Claude (default: current)" |
|
echo " -p, --max-parallel N Worker pool size (default: 10)" |
|
echo " -v, --verbose Enable audio notifications" |
|
echo " -s, --status Show task counts" |
|
echo " -r, --reset-failed Move failed tasks back to pending" |
|
echo " --reset-wip Move WIP tasks back to pending" |
|
echo " --init Initialize task directories" |
|
echo " -h, --help Show this help" |
|
echo "" |
|
echo "Directory Structure:" |
|
echo " tasks/pending/ Tasks waiting to run" |
|
echo " tasks/wip/ Currently running" |
|
echo " tasks/completed/ Finished successfully" |
|
echo " tasks/failed/ Failed tasks" |
|
echo "" |
|
echo "See tasks.MD for how to write task files." |
|
exit 0 |
|
;; |
|
*) |
|
echo -e "${RED}Unknown option: $1${NC}" |
|
echo "Use --help for usage" |
|
exit 1 |
|
;; |
|
esac |
|
done |
|
|
|
# Ensure directories exist before running |
|
if [ ! -d "$TASKS_DIR/pending" ]; then |
|
echo -e "${YELLOW}Task directories not found. Initializing...${NC}" |
|
init_task_dirs |
|
fi |
|
|
|
# Run main |
|
main |