|
#!/usr/bin/env bash |
|
set -uo pipefail |
|
# Note: no `set -e` — a long-running daemon must not exit on transient failures |
|
# (e.g., a single bad JSON response from Linear). Errors are handled explicitly. |
|
|
|
# === Bash Version Check === |
|
if (( BASH_VERSINFO[0] < 4 )); then |
|
echo "ERROR: bash 4+ required (found bash $BASH_VERSION). Install via: brew install bash" >&2 |
|
exit 1 |
|
fi |
|
|
|
# === Dependency Check === |
|
for cmd in curl jq claude; do |
|
if ! command -v "$cmd" &>/dev/null; then |
|
echo "ERROR: Required command '$cmd' not found on PATH" >&2 |
|
exit 1 |
|
fi |
|
done |
|
|
|
# === Constants === |
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" |
|
PID_FILE="$SCRIPT_DIR/.linear-daemon.pid" |
|
LOG_DIR="$PROJECT_DIR/logs" |
|
DAEMON_LOG="$LOG_DIR/daemon.log" |
|
POLL_INTERVAL=60 |
|
MAX_WORKERS=5 |
|
WORKER_TIMEOUT=1800 # 30 minutes in seconds |
|
|
|
# === Linear API Constants === |
|
LINEAR_API_URL="https://api.linear.app/graphql" |
|
LINEAR_LABEL="${LINEAR_LABEL:-claude}" # Label that triggers daemon pickup |
|
|
|
# === Load .env === |
|
if [[ -f "$SCRIPT_DIR/.env" ]]; then |
|
# shellcheck source=/dev/null |
|
source "$SCRIPT_DIR/.env" |
|
fi |
|
|
|
# === Required Environment Variables === |
|
for var in LINEAR_API_KEY LINEAR_TEAM_ID LINEAR_STATUS_TODO LINEAR_STATUS_IN_PROGRESS LINEAR_STATUS_DONE; do |
|
if [[ -z "${!var:-}" ]]; then |
|
echo "ERROR: $var not set. See .env.example" >&2 |
|
exit 1 |
|
fi |
|
done |
|
|
|
# Optional: assign completed issues back to a specific user |
|
LINEAR_OWNER_ID="${LINEAR_OWNER_ID:-}" |
|
|
|
# === Worker Tracking === |
|
# Associative arrays: keyed by PID |
|
declare -A WORKER_ISSUE_ID=() # PID → Linear issue UUID |
|
declare -A WORKER_IDENTIFIER=() # PID → issue identifier (e.g. PROJ-170) |
|
declare -A WORKER_START_TIME=() # PID → epoch timestamp |
|
STATUS_FILE="$SCRIPT_DIR/.linear-daemon.status" |
|
|
|
# Write current worker status to file (readable by cmd_status in a separate process) |
|
update_status_file() { |
|
local count=${#WORKER_ISSUE_ID[@]} |
|
local identifiers="" |
|
for pid in "${!WORKER_IDENTIFIER[@]}"; do |
|
identifiers+=" ${WORKER_IDENTIFIER[$pid]}" |
|
done |
|
echo "Active workers: $count / $MAX_WORKERS" > "$STATUS_FILE" |
|
if [[ -n "$identifiers" ]]; then |
|
echo "Working on:$identifiers" >> "$STATUS_FILE" |
|
fi |
|
} |
|
|
|
# === Usage === |
|
usage() { |
|
echo "Usage: $0 {start|stop|status|restart}" |
|
echo "" |
|
echo " start Start the daemon in the background" |
|
echo " stop Stop the running daemon" |
|
echo " status Show daemon and worker status" |
|
echo " restart Stop then start" |
|
exit 1 |
|
} |
|
|
|
# === PID Helpers === |
|
is_running() { |
|
local pid_file="$1" |
|
if [[ ! -f "$pid_file" ]]; then |
|
return 1 |
|
fi |
|
local pid |
|
pid=$(cat "$pid_file") |
|
if kill -0 "$pid" 2>/dev/null; then |
|
return 0 |
|
else |
|
rm -f "$pid_file" |
|
return 1 |
|
fi |
|
} |
|
|
|
write_pid() { |
|
echo $$ > "$PID_FILE" |
|
} |
|
|
|
remove_pid() { |
|
rm -f "$PID_FILE" |
|
} |
|
|
|
# === Logging === |
|
log() { |
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" |
|
} |
|
|
|
# === Linear API === |
|
linear_query() { |
|
local query="$1" |
|
local response |
|
response=$(curl -s -w "\n%{http_code}" -X POST "$LINEAR_API_URL" \ |
|
-H "Content-Type: application/json" \ |
|
-H "Authorization: $LINEAR_API_KEY" \ |
|
-d "$query" 2>/dev/null) || { |
|
log "ERROR: curl failed for Linear API" |
|
return 1 |
|
} |
|
|
|
local http_code body |
|
http_code=$(echo "$response" | tail -1) |
|
body=$(echo "$response" | sed '$d') |
|
|
|
if [[ "$http_code" -ge 400 ]]; then |
|
log "ERROR: Linear API returned HTTP $http_code: $body" |
|
return 1 |
|
fi |
|
|
|
echo "$body" |
|
} |
|
|
|
fetch_claude_issues() { |
|
local query |
|
query=$(cat <<GRAPHQL |
|
{ |
|
"query": "{ issues(filter: { team: { id: { eq: \"$LINEAR_TEAM_ID\" } }, labels: { name: { eq: \"$LINEAR_LABEL\" } }, state: { id: { eq: \"$LINEAR_STATUS_TODO\" } } }, orderBy: createdAt) { nodes { id identifier title description } } }" |
|
} |
|
GRAPHQL |
|
) |
|
|
|
local result |
|
result=$(linear_query "$query") || return 1 |
|
echo "$result" | jq -c '.data.issues.nodes[]' 2>/dev/null |
|
} |
|
|
|
update_issue_status() { |
|
local issue_id="$1" |
|
local status_id="$2" |
|
local query |
|
query=$(printf '{"query": "mutation { issueUpdate(id: \\"%s\\", input: { stateId: \\"%s\\" }) { success } }"}' "$issue_id" "$status_id") |
|
linear_query "$query" > /dev/null |
|
} |
|
|
|
assign_issue() { |
|
local issue_id="$1" |
|
local assignee_id="$2" |
|
local query |
|
query=$(printf '{"query": "mutation { issueUpdate(id: \\"%s\\", input: { assigneeId: \\"%s\\" }) { success } }"}' "$issue_id" "$assignee_id") |
|
linear_query "$query" > /dev/null |
|
} |
|
|
|
comment_on_issue() { |
|
local issue_id="$1" |
|
local body="$2" |
|
# Escape newlines and quotes for JSON |
|
body=$(echo "$body" | jq -Rs '.') |
|
local query |
|
query=$(printf '{"query": "mutation { commentCreate(input: { issueId: \\"%s\\", body: %s }) { success } }"}' "$issue_id" "$body") |
|
linear_query "$query" > /dev/null |
|
} |
|
|
|
# === Worker Management === |
|
build_prompt() { |
|
local identifier="$1" |
|
local title="$2" |
|
local description="$3" |
|
cat <<PROMPT |
|
You are an autonomous Claude Code agent. |
|
|
|
## Your Task |
|
Issue: $identifier |
|
Title: $title |
|
Description: |
|
$description |
|
|
|
## AUTONOMY RULES — READ CAREFULLY |
|
NO HUMAN IN THE LOOP. DO NOT WAIT FOR INPUT. DO NOT ASK QUESTIONS. |
|
LOG YOUR OWN DECISIONS INSTEAD. |
|
|
|
If any skill or workflow tells you to: |
|
- "discuss with human" → skip, comment your reasoning on the Linear issue |
|
- "ask for help" or "get approval" → skip, make your best judgment call |
|
- "wait for user response" → skip, proceed with your best judgment |
|
- present options for the user to choose → choose the simplest option yourself |
|
|
|
If you truly cannot proceed autonomously, comment WHY on the Linear issue |
|
(identifier: $identifier), move it to "Todo", and exit immediately. |
|
|
|
## Workflow |
|
1. Read the issue carefully. Explore the codebase to understand context. |
|
2. Create an implementation plan. |
|
3. Execute the plan. Use subagents for multi-step work if available. |
|
4. If you encounter bugs or test failures, debug systematically. |
|
5. Run all relevant tests and ensure they pass. |
|
6. Merge your worktree branch into main. |
|
7. Push to origin. |
|
|
|
## Merge Conflicts |
|
If the merge to main fails due to conflicts: |
|
- DO NOT force-push or discard changes. |
|
- Comment on the Linear issue explaining which files conflict. |
|
- Move the issue to "Todo" and exit. A human will resolve the conflict. |
|
|
|
## Progress Updates |
|
Comment on the Linear issue (identifier: $identifier) at these milestones: |
|
- When you've finished investigating and have a plan |
|
- When implementation is complete and tests pass |
|
- If you hit a blocker or decide to bail out |
|
|
|
## Bail-Out Conditions |
|
Move the issue back to "Todo" and comment explaining why if: |
|
- The task requires architectural decisions beyond your confidence |
|
- You've failed 3+ fix attempts on the same problem |
|
- The scope is significantly larger than the issue description suggests |
|
- You cannot proceed without human input that you can't reasonably infer |
|
- Merge to main fails due to conflicts |
|
PROMPT |
|
} |
|
|
|
spawn_worker() { |
|
local issue_id="$1" |
|
local identifier="$2" |
|
local title="$3" |
|
local description="$4" |
|
|
|
local log_file="$LOG_DIR/${identifier}.jsonl" |
|
local prompt |
|
prompt=$(build_prompt "$identifier" "$title" "$description") |
|
|
|
log "Spawning worker for $identifier: $title" |
|
|
|
# Move issue to In Progress |
|
update_issue_status "$issue_id" "$LINEAR_STATUS_IN_PROGRESS" |
|
|
|
# Spawn Claude Code with process substitution for correct exit code capture. |
|
# CLAUDECODE= unsets the env var that prevents nested Claude Code sessions. |
|
# Note: process substitution means tee may not flush its final buffer before |
|
# wait returns — transcript logs may be missing the last few lines on crash/kill. |
|
CLAUDECODE= claude -p "$prompt" \ |
|
--worktree \ |
|
--permission-mode bypassPermissions \ |
|
--output-format stream-json \ |
|
--verbose \ |
|
> >(tee "$log_file") 2>&1 & |
|
local pid=$! |
|
|
|
WORKER_ISSUE_ID[$pid]="$issue_id" |
|
WORKER_IDENTIFIER[$pid]="$identifier" |
|
WORKER_START_TIME[$pid]=$(date +%s) |
|
|
|
log "Worker $identifier started (PID $pid)" |
|
update_status_file |
|
} |
|
|
|
reap_workers() { |
|
local now |
|
now=$(date +%s) |
|
|
|
for pid in "${!WORKER_ISSUE_ID[@]}"; do |
|
local issue_id="${WORKER_ISSUE_ID[$pid]}" |
|
local identifier="${WORKER_IDENTIFIER[$pid]}" |
|
local start_time="${WORKER_START_TIME[$pid]}" |
|
local elapsed=$(( now - start_time )) |
|
local transcript_path |
|
transcript_path="$PROJECT_DIR/logs/${identifier}.jsonl" |
|
|
|
# Check if still running |
|
if kill -0 "$pid" 2>/dev/null; then |
|
# Check timeout |
|
if (( elapsed > WORKER_TIMEOUT )); then |
|
log "Worker $identifier (PID $pid) timed out after ${elapsed}s — killing" |
|
kill "$pid" 2>/dev/null || true |
|
sleep 2 |
|
kill -9 "$pid" 2>/dev/null || true |
|
wait "$pid" 2>/dev/null || true |
|
update_issue_status "$issue_id" "$LINEAR_STATUS_TODO" |
|
comment_on_issue "$issue_id" "⏱ Timed out after 30 minutes. Transcript: $transcript_path" |
|
cleanup_worktree "$pid" |
|
unset "WORKER_ISSUE_ID[$pid]" |
|
unset "WORKER_IDENTIFIER[$pid]" |
|
unset "WORKER_START_TIME[$pid]" |
|
update_status_file |
|
fi |
|
else |
|
# Process exited — capture exit code |
|
local exit_code=0 |
|
wait "$pid" 2>/dev/null || exit_code=$? |
|
|
|
if (( exit_code == 0 )); then |
|
log "Worker $identifier (PID $pid) completed successfully" |
|
update_issue_status "$issue_id" "$LINEAR_STATUS_DONE" |
|
if [[ -n "$LINEAR_OWNER_ID" ]]; then |
|
assign_issue "$issue_id" "$LINEAR_OWNER_ID" |
|
fi |
|
comment_on_issue "$issue_id" "✅ Completed. Transcript: $transcript_path" |
|
else |
|
log "Worker $identifier (PID $pid) failed (exit code $exit_code)" |
|
update_issue_status "$issue_id" "$LINEAR_STATUS_TODO" |
|
comment_on_issue "$issue_id" "❌ Failed (exit code $exit_code). Transcript: $transcript_path" |
|
fi |
|
|
|
cleanup_worktree "$pid" |
|
unset "WORKER_ISSUE_ID[$pid]" |
|
unset "WORKER_IDENTIFIER[$pid]" |
|
unset "WORKER_START_TIME[$pid]" |
|
update_status_file |
|
fi |
|
done |
|
} |
|
|
|
worker_count() { |
|
echo "${#WORKER_ISSUE_ID[@]}" |
|
} |
|
|
|
cleanup_worktree() { |
|
local pid="$1" |
|
local identifier="${WORKER_IDENTIFIER[$pid]:-}" |
|
local log_file="$LOG_DIR/${identifier}.jsonl" |
|
|
|
# Extract worktree path from the completed transcript (stable, no race) |
|
local wt="" |
|
local br="" |
|
if [[ -n "$identifier" && -f "$log_file" ]]; then |
|
wt=$(grep -m1 '"subtype":"init"' "$log_file" | jq -r '.cwd // empty' 2>/dev/null) |
|
if [[ -n "$wt" && -d "$wt" ]]; then |
|
br=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null) |
|
fi |
|
fi |
|
|
|
if [[ -z "$wt" ]]; then |
|
if [[ -n "$identifier" ]]; then |
|
log "WARNING: Could not extract worktree path for $identifier — orphan cleanup may be needed" |
|
fi |
|
return 0 |
|
fi |
|
|
|
local resolved_wt safe_prefix |
|
resolved_wt=$(realpath "$wt" 2>/dev/null) || true |
|
safe_prefix=$(realpath "$PROJECT_DIR/.claude/worktrees" 2>/dev/null) || true |
|
if [[ -n "$resolved_wt" && -n "$safe_prefix" && -d "$wt" && "$resolved_wt" == "$safe_prefix"/* ]]; then |
|
rm -rf "$wt" |
|
git -C "$PROJECT_DIR" worktree prune |
|
if [[ -n "$br" ]]; then |
|
git -C "$PROJECT_DIR" branch -d "$br" 2>/dev/null || true |
|
fi |
|
log "Cleaned up worktree: $wt" |
|
fi |
|
} |
|
|
|
cleanup() { |
|
SHUTTING_DOWN=true |
|
log "Shutting down — stopping ${#WORKER_ISSUE_ID[@]} worker(s)..." |
|
|
|
# Move all in-progress issues back to Todo and comment |
|
for pid in "${!WORKER_ISSUE_ID[@]}"; do |
|
local issue_id="${WORKER_ISSUE_ID[$pid]}" |
|
local identifier="${WORKER_IDENTIFIER[$pid]}" |
|
local transcript_path="$PROJECT_DIR/logs/${identifier}.jsonl" |
|
log "Stopping worker $identifier (PID $pid)" |
|
update_issue_status "$issue_id" "$LINEAR_STATUS_TODO" || true |
|
comment_on_issue "$issue_id" "⚠️ Daemon stopped while work was in progress. Transcript (partial): $transcript_path" || true |
|
kill "$pid" 2>/dev/null || true |
|
done |
|
|
|
# Wait up to 10 seconds for workers to exit |
|
local i=0 |
|
while (( i < 10 )); do |
|
local any_alive=false |
|
for pid in "${!WORKER_ISSUE_ID[@]}"; do |
|
if kill -0 "$pid" 2>/dev/null; then |
|
any_alive=true |
|
break |
|
fi |
|
done |
|
if [[ "$any_alive" == "false" ]]; then |
|
break |
|
fi |
|
sleep 1 |
|
(( i++ )) |
|
done |
|
|
|
# Force-kill any stragglers |
|
for pid in "${!WORKER_ISSUE_ID[@]}"; do |
|
if kill -0 "$pid" 2>/dev/null; then |
|
log "Force-killing worker PID $pid" |
|
kill -9 "$pid" 2>/dev/null || true |
|
fi |
|
done |
|
|
|
# Clean up all worktrees |
|
for pid in "${!WORKER_ISSUE_ID[@]}"; do |
|
cleanup_worktree "$pid" |
|
done |
|
|
|
rm -f "$STATUS_FILE" |
|
remove_pid |
|
log "Daemon stopped." |
|
} |
|
|
|
poll_and_spawn() { |
|
log "Polling Linear for $LINEAR_LABEL-labeled issues..." |
|
local issues |
|
issues=$(fetch_claude_issues) || { |
|
log "Poll failed — will retry next cycle" |
|
return 0 |
|
} |
|
|
|
if [[ -z "$issues" ]]; then |
|
log "No issues found" |
|
return 0 |
|
fi |
|
|
|
log "Found issues to process" |
|
|
|
# Build set of issue IDs already being worked on |
|
local -A active_issues |
|
for pid in "${!WORKER_ISSUE_ID[@]}"; do |
|
active_issues["${WORKER_ISSUE_ID[$pid]}"]=1 |
|
done |
|
|
|
while IFS= read -r issue_json; do |
|
local count |
|
count=$(worker_count) |
|
if (( count >= MAX_WORKERS )); then |
|
log "Reached max workers ($MAX_WORKERS) — deferring remaining issues" |
|
break |
|
fi |
|
|
|
local issue_id identifier title description |
|
issue_id=$(echo "$issue_json" | jq -r '.id') |
|
identifier=$(echo "$issue_json" | jq -r '.identifier') |
|
title=$(echo "$issue_json" | jq -r '.title') |
|
description=$(echo "$issue_json" | jq -r '.description // "No description provided."') |
|
|
|
# Skip if already being worked on |
|
if [[ -n "${active_issues[$issue_id]:-}" ]]; then |
|
continue |
|
fi |
|
|
|
spawn_worker "$issue_id" "$identifier" "$title" "$description" |
|
done <<< "$issues" |
|
} |
|
|
|
# === Commands === |
|
cmd_start() { |
|
if is_running "$PID_FILE"; then |
|
echo "ERROR: Daemon is already running (PID $(cat "$PID_FILE"))" >&2 |
|
exit 1 |
|
fi |
|
|
|
mkdir -p "$LOG_DIR" |
|
|
|
echo "Starting linear daemon..." |
|
nohup bash "$0" _run >> "$DAEMON_LOG" 2>&1 & |
|
local bg_pid=$! |
|
sleep 1 |
|
if is_running "$PID_FILE"; then |
|
echo "Daemon started (PID $(cat "$PID_FILE")). Logs: $DAEMON_LOG" |
|
else |
|
echo "ERROR: Daemon failed to start. Check $DAEMON_LOG" >&2 |
|
exit 1 |
|
fi |
|
} |
|
|
|
cmd_stop() { |
|
if ! is_running "$PID_FILE"; then |
|
echo "Daemon is not running." |
|
return 0 |
|
fi |
|
local pid |
|
pid=$(cat "$PID_FILE") |
|
echo "Stopping daemon (PID $pid)..." |
|
kill "$pid" 2>/dev/null || true |
|
local i=0 |
|
while kill -0 "$pid" 2>/dev/null && (( i < 10 )); do |
|
sleep 1 |
|
(( i++ )) |
|
done |
|
if kill -0 "$pid" 2>/dev/null; then |
|
echo "Force-killing daemon..." |
|
kill -9 "$pid" 2>/dev/null || true |
|
fi |
|
remove_pid |
|
echo "Daemon stopped." |
|
} |
|
|
|
cmd_status() { |
|
if is_running "$PID_FILE"; then |
|
local pid |
|
pid=$(cat "$PID_FILE") |
|
echo "Daemon is running (PID $pid)" |
|
local status_file="$SCRIPT_DIR/.linear-daemon.status" |
|
if [[ -f "$status_file" ]]; then |
|
cat "$status_file" |
|
else |
|
echo "Active workers: unknown (status file not yet written)" |
|
fi |
|
else |
|
echo "Daemon is not running." |
|
fi |
|
} |
|
|
|
cmd_run() { |
|
write_pid |
|
log "Daemon started (PID $$)" |
|
|
|
SHUTTING_DOWN=false |
|
trap cleanup SIGTERM SIGINT EXIT |
|
|
|
while [[ "$SHUTTING_DOWN" == "false" ]]; do |
|
# Reap completed workers first |
|
reap_workers |
|
|
|
# Poll for new issues if under capacity |
|
local count |
|
count=$(worker_count) |
|
if (( count < MAX_WORKERS )); then |
|
poll_and_spawn |
|
else |
|
log "At capacity ($count/$MAX_WORKERS workers) — skipping poll" |
|
fi |
|
|
|
# Sleep in 1-second increments so we can respond to signals |
|
local i=0 |
|
while (( i < POLL_INTERVAL )) && [[ "$SHUTTING_DOWN" == "false" ]]; do |
|
sleep 1 |
|
(( i++ )) |
|
# Also reap during sleep so we notice exits quickly |
|
reap_workers |
|
done |
|
done |
|
} |
|
|
|
# === CLI Dispatch === |
|
case "${1:-}" in |
|
start) cmd_start ;; |
|
stop) cmd_stop ;; |
|
status) cmd_status ;; |
|
restart) cmd_stop; cmd_start ;; |
|
_run) cmd_run ;; |
|
*) usage ;; |
|
esac |