fleet.sh 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. #!/usr/bin/env bash
  2. # fleet-ops — landing queue manager for concurrent Claude sessions
  3. # Status: experimental
  4. set -euo pipefail
  5. FLEET_DIR=".claude/fleet"
  6. LANES_DIR="$FLEET_DIR/lanes"
  7. LOG="$FLEET_DIR/activity.log"
  8. CONFIG="$FLEET_DIR/config"
  9. PID_FILE="$FLEET_DIR/daemon.pid"
  10. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  11. # Shared terminal-output helpers (see docs/DESIGN.md).
  12. # shellcheck source=../../_lib/term.sh
  13. . "$SCRIPT_DIR/../../_lib/term.sh"
  14. # Honor legacy FLEET_ASCII alongside TERM_ASCII.
  15. [[ "${FLEET_ASCII:-}" == "1" || "${icons:-}" == "ascii" ]] && export TERM_ASCII=1
  16. term_init
  17. # defaults (overridable via .claude/fleet/config: key=value, no quotes)
  18. MODE="auto"
  19. WORKTREE_ROOT=".claude/fleet/worktrees"
  20. TEST_CMD=""
  21. FORBIDDEN_PATTERN="TODO_SCRUB|XXX[^a-z]|FIXME_BEFORE_LAND"
  22. BASE_BRANCH="main"
  23. POLL_INTERVAL=5
  24. [[ -f "$CONFIG" ]] && source "$CONFIG" 2>/dev/null || true
  25. # Icons resolved through the shared term lib (term_state_icon).
  26. ICON_RUNNING="$(term_state_icon RUNNING)"
  27. ICON_READY="$(term_state_icon READY)"
  28. ICON_LANDED="$(term_state_icon LANDED)"
  29. ICON_FAILED="$(term_state_icon FAILED)"
  30. ICON_CONFLICT="$(term_state_icon CONFLICT)"
  31. ICON_UNKNOWN="?"
  32. # Cross-platform mtime: GNU stat (Linux/Git Bash) vs BSD stat (macOS)
  33. file_mtime() {
  34. stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || date +%s
  35. }
  36. log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG" >&2; }
  37. ensure_fleet_dir() {
  38. mkdir -p "$LANES_DIR"
  39. [[ -f "$FLEET_DIR/signal.sh" ]] || cp "$SCRIPT_DIR/signal.sh" "$FLEET_DIR/signal.sh"
  40. chmod +x "$FLEET_DIR/signal.sh" 2>/dev/null || true
  41. # Auto-ignore .claude/fleet/ in git so it doesn't show as "dirty" or get committed
  42. if [[ -d .git ]] || git rev-parse --git-dir >/dev/null 2>&1; then
  43. if [[ ! -f .gitignore ]] || ! grep -qxF '.claude/fleet/' .gitignore 2>/dev/null; then
  44. echo '.claude/fleet/' >> .gitignore
  45. fi
  46. fi
  47. }
  48. is_dirty_tracked() {
  49. # True only if tracked files have uncommitted changes (ignores untracked files)
  50. ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null
  51. }
  52. lane_state() { [[ -f "$LANES_DIR/$1" ]] && head -n1 "$LANES_DIR/$1" || echo "MISSING"; }
  53. set_lane_state() {
  54. local l=$1 s=$2
  55. shift 2
  56. if [[ $# -gt 0 ]]; then
  57. printf '%s\n%s\n' "$s" "$*" > "$LANES_DIR/$l"
  58. else
  59. printf '%s\n' "$s" > "$LANES_DIR/$l"
  60. fi
  61. }
  62. scrub_diff() {
  63. # echoes hits (one per line) for given branch's diff vs base. Empty = clean.
  64. local branch=$1
  65. git diff "$BASE_BRANCH"..."$branch" 2>/dev/null | grep -nE "$FORBIDDEN_PATTERN" || true
  66. }
  67. refuse_if_shared_tree() {
  68. local trees lane_count
  69. trees=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | sort -u | wc -l)
  70. lane_count=$(ls -1 "$LANES_DIR" 2>/dev/null | wc -l)
  71. if [[ "$lane_count" -gt 1 && "$trees" -le 1 && "$MODE" != "branch" ]]; then
  72. log "ERROR: $lane_count lanes but only $trees worktree — sessions will collide"
  73. log " Use worktrees, separate clones, or set mode=branch in $CONFIG to override"
  74. return 1
  75. fi
  76. }
  77. cmd_init() {
  78. ensure_fleet_dir
  79. [[ $# -eq 0 ]] && { echo "usage: fleet init <name>..." >&2; exit 1; }
  80. local mode="$MODE"
  81. [[ "$mode" == "auto" ]] && mode="worktree" # default: worktree if git allows it
  82. for name in "$@"; do
  83. if git rev-parse --verify "$name" >/dev/null 2>&1; then
  84. log "skip branch (exists): $name"
  85. else
  86. git branch "$name" "$BASE_BRANCH"
  87. log "created branch: $name"
  88. fi
  89. if [[ "$mode" == "worktree" ]]; then
  90. local wt="$WORKTREE_ROOT/$name"
  91. if [[ -d "$wt" ]]; then
  92. log "skip worktree (exists): $wt"
  93. else
  94. mkdir -p "$WORKTREE_ROOT"
  95. git worktree add "$wt" "$name"
  96. log "created worktree: $wt"
  97. fi
  98. fi
  99. set_lane_state "$name" "RUNNING"
  100. done
  101. echo ""
  102. echo "Fleet initialized. Hand each session the prompt template:"
  103. echo " $SCRIPT_DIR/../references/session-prompt.md"
  104. echo "Then: bash $0 start"
  105. }
  106. format_age() {
  107. local secs=$1
  108. if [[ $secs -lt 60 ]]; then printf '%ds' "$secs"
  109. elif [[ $secs -lt 3600 ]]; then printf '%dm' "$((secs/60))"
  110. else printf '%dh%dm' "$((secs/3600))" "$(( (secs%3600)/60 ))"
  111. fi
  112. }
  113. cmd_fleet() {
  114. ensure_fleet_dir
  115. # Bucket lanes by state. ASCII-safe assoc-array alternative: parallel arrays.
  116. local order=(RUNNING READY CONFLICT FAILED LANDED)
  117. local now total=0 active=0
  118. now=$(date +%s)
  119. # state_buckets[i] = newline-joined "branch|age|meta" rows for order[i]
  120. local state_buckets=("" "" "" "" "")
  121. local state_counts=(0 0 0 0 0)
  122. for f in "$LANES_DIR"/*; do
  123. [[ -f "$f" ]] || continue
  124. total=$((total+1))
  125. local branch state meta mtime secs age idx
  126. branch=$(basename "$f")
  127. state=$(head -n1 "$f")
  128. meta=$(sed -n '2p' "$f")
  129. mtime=$(file_mtime "$f")
  130. secs=$((now - mtime))
  131. age=$(format_age "$secs")
  132. [[ "$state" != "LANDED" && "$state" != "FAILED" ]] && active=$((active+1))
  133. idx=-1
  134. case "$state" in
  135. RUNNING) idx=0 ;;
  136. READY) idx=1 ;;
  137. CONFLICT) idx=2 ;;
  138. FAILED) idx=3 ;;
  139. LANDED) idx=4 ;;
  140. esac
  141. [[ $idx -lt 0 ]] && continue
  142. state_counts[$idx]=$(( state_counts[idx] + 1 ))
  143. state_buckets[$idx]="${state_buckets[$idx]}${branch}|${age}|${meta}"$'\n'
  144. done
  145. # Daemon health for the footer
  146. local daemon_state="busted"
  147. if [[ -f "$PID_FILE" ]]; then
  148. local pid
  149. pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
  150. if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
  151. daemon_state="healthy"
  152. fi
  153. fi
  154. # Footer composition (reused on every render path)
  155. local hotkeys
  156. hotkeys="$(term_hotkey R refresh) · $(term_hotkey L land) · $(term_hotkey '?' help)"
  157. local healths
  158. healths="$(term_health "$daemon_state" "daemon")"
  159. [[ $total -gt 0 ]] && healths="$healths $(term_health pending "$active active")"
  160. echo ""
  161. term_panel_open fleet fleet "$TERM_GLYPH_BRANCH $BASE_BRANCH"
  162. if [[ $total -eq 0 ]]; then
  163. # Empty state: tip + suggested commands
  164. term_panel_vert
  165. term_panel_vert
  166. printf '%s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "no lanes yet"
  167. term_panel_vert
  168. term_panel_vert
  169. printf '%s %s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "$TERM_GLYPH_TIP" "to get started:"
  170. term_panel_vert
  171. printf '%s 1. fleet init <name>...\n' "$(term_color dim "$TERM_TREE_VERT")"
  172. printf '%s 2. (work in each lane)\n' "$(term_color dim "$TERM_TREE_VERT")"
  173. printf '%s 3. fleet start\n' "$(term_color dim "$TERM_TREE_VERT")"
  174. term_panel_vert
  175. term_panel_vert
  176. term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "v2.4.9")"
  177. echo ""
  178. return
  179. fi
  180. # Summary branch + breath
  181. term_panel_vert
  182. term_summary_line "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) · $active active"
  183. term_panel_vert
  184. # State sections with leaves underneath
  185. local i
  186. for i in 0 1 2 3 4; do
  187. local n=${state_counts[$i]}
  188. [[ $n -eq 0 ]] && continue
  189. local state=${order[$i]}
  190. term_section "$state" "$state" "$n"
  191. local lines="${state_buckets[$i]}"
  192. local c_idx=0 c_last=$((n - 1))
  193. local branch age meta
  194. while IFS='|' read -r branch age meta; do
  195. [[ -z "$branch" ]] && continue
  196. local c_conn
  197. if [[ $c_idx -eq $c_last ]]; then c_conn="$TERM_TREE_LAST"; else c_conn="$TERM_TREE_BRANCH"; fi
  198. # Build the rail glyph from this lane's commits-ahead and state.
  199. local ahead head_kind rail
  200. ahead=$(git rev-list --count "${BASE_BRANCH}..${branch}" 2>/dev/null || echo 0)
  201. head_kind="HEAD"
  202. [[ "$state" == "CONFLICT" ]] && head_kind="CONFLICT"
  203. [[ "$state" == "FAILED" ]] && head_kind="CONFLICT"
  204. rail=$(term_rail "$ahead" "$head_kind")
  205. term_leaf_line "$c_conn" "$branch" "$rail" "${meta:-}" "$age"
  206. c_idx=$((c_idx+1))
  207. done <<< "$lines"
  208. term_panel_vert
  209. done
  210. term_panel_close "$hotkeys" "$healths"
  211. echo ""
  212. }
  213. cmd_scrub_check() {
  214. local branch=${1:-}
  215. [[ -z "$branch" ]] && { echo "usage: fleet scrub-check <branch>" >&2; exit 1; }
  216. local hits
  217. hits=$(scrub_diff "$branch")
  218. if [[ -n "$hits" ]]; then
  219. echo "FORBIDDEN PATTERNS in $branch:"
  220. echo "$hits" | head -20
  221. return 1
  222. fi
  223. echo "OK: $branch (no forbidden patterns)"
  224. }
  225. land_one() {
  226. local branch=$1
  227. local hits
  228. hits=$(scrub_diff "$branch")
  229. if [[ -n "$hits" ]]; then
  230. log "REFUSE LAND: $branch failed scrub-check"
  231. echo "$hits" | head -10 | tee -a "$LOG"
  232. set_lane_state "$branch" "CONFLICT" "scrub-check failed"
  233. return 1
  234. fi
  235. if is_dirty_tracked; then
  236. log "REFUSE LAND: $BASE_BRANCH has uncommitted tracked changes — clean before landing"
  237. return 1
  238. fi
  239. log "LANDING: $branch"
  240. git checkout "$BASE_BRANCH"
  241. if git merge "$branch" --no-ff -m "merge: $branch"; then
  242. if [[ -n "$TEST_CMD" ]]; then
  243. log "running tests: $TEST_CMD"
  244. if eval "$TEST_CMD" >>"$LOG" 2>&1; then
  245. log "PASS: $branch landed ✓"
  246. else
  247. log "FAIL: tests failed — reverting $branch"
  248. git reset --hard HEAD^
  249. set_lane_state "$branch" "FAILED" "tests failed post-merge"
  250. return 1
  251. fi
  252. else
  253. log "no test_cmd set — trusting signal.sh's log gate"
  254. fi
  255. set_lane_state "$branch" "LANDED"
  256. git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true
  257. return 0
  258. else
  259. log "MERGE CONFLICT: $branch"
  260. git merge --abort 2>/dev/null || true
  261. set_lane_state "$branch" "CONFLICT" "merge conflict with $BASE_BRANCH"
  262. return 1
  263. fi
  264. }
  265. worktree_path_for() {
  266. # Echo the worktree path for branch $1, or empty if branch isn't in a worktree
  267. local branch=$1
  268. git worktree list --porcelain 2>/dev/null | awk -v want="refs/heads/$branch" '
  269. /^worktree /{p=$2}
  270. /^branch /{ if ($2==want) print p }
  271. '
  272. }
  273. rebase_others() {
  274. local landed=$1
  275. for f in "$LANES_DIR"/*; do
  276. local b state wt
  277. b=$(basename "$f")
  278. [[ "$b" == "$landed" ]] && continue
  279. state=$(lane_state "$b")
  280. [[ "$state" == "LANDED" || "$state" == "FAILED" ]] && continue
  281. git rev-parse --verify "$b" >/dev/null 2>&1 || continue
  282. log "rebase: $b onto $BASE_BRANCH"
  283. wt=$(worktree_path_for "$b")
  284. if [[ -n "$wt" ]]; then
  285. # Branch is checked out in a worktree — run rebase from there
  286. if git -C "$wt" rebase "$BASE_BRANCH" 2>>"$LOG"; then
  287. log "rebase OK: $b (in worktree $wt)"
  288. else
  289. log "rebase CONFLICT: $b"
  290. git -C "$wt" rebase --abort 2>/dev/null || true
  291. set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
  292. fi
  293. else
  294. # Plain branch (no worktree) — rebase via the main repo
  295. if git rebase "$BASE_BRANCH" "$b" 2>>"$LOG"; then
  296. log "rebase OK: $b"
  297. else
  298. log "rebase CONFLICT: $b"
  299. git rebase --abort 2>/dev/null || true
  300. set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
  301. fi
  302. fi
  303. done
  304. git checkout "$BASE_BRANCH" 2>/dev/null || true
  305. }
  306. cmd_land() {
  307. local branch=${1:-}
  308. [[ -z "$branch" ]] && { echo "usage: fleet land <branch>" >&2; exit 1; }
  309. land_one "$branch" && rebase_others "$branch"
  310. }
  311. cmd_stop() {
  312. if [[ ! -f "$PID_FILE" ]]; then
  313. echo "no daemon running (no $PID_FILE)" >&2
  314. return 0
  315. fi
  316. local pid
  317. pid=$(cat "$PID_FILE")
  318. if ! kill -0 "$pid" 2>/dev/null; then
  319. log "stale PID file (pid $pid not alive) — clearing"
  320. rm -f "$PID_FILE"
  321. return 0
  322. fi
  323. log "sending SIGTERM to daemon (pid $pid)"
  324. kill -TERM "$pid" 2>/dev/null || true
  325. # Wait up to 5s for graceful exit
  326. local i
  327. for i in 1 2 3 4 5; do
  328. sleep 1
  329. kill -0 "$pid" 2>/dev/null || { log "daemon stopped"; return 0; }
  330. done
  331. log "daemon didn't exit on SIGTERM, sending SIGKILL"
  332. kill -KILL "$pid" 2>/dev/null || true
  333. rm -f "$PID_FILE"
  334. }
  335. cmd_revert() {
  336. local branch=${1:-}
  337. [[ -z "$branch" ]] && { echo "usage: fleet revert <branch>" >&2; exit 1; }
  338. local sha
  339. sha=$(git log "$BASE_BRANCH" --merges --grep="merge: $branch" -n1 --format=%H)
  340. [[ -z "$sha" ]] && { log "ERROR: no merge commit found for $branch on $BASE_BRANCH"; exit 1; }
  341. log "reverting merge $sha (was: $branch)"
  342. git checkout "$BASE_BRANCH"
  343. git revert -m 1 "$sha" --no-edit
  344. log "reverted: $branch"
  345. }
  346. daemon_cleanup() {
  347. log "daemon stopping (pid $$)"
  348. rm -f "$PID_FILE"
  349. }
  350. cmd_start() {
  351. ensure_fleet_dir
  352. refuse_if_shared_tree || exit 1
  353. # Refuse if a daemon is already running
  354. if [[ -f "$PID_FILE" ]]; then
  355. local existing_pid
  356. existing_pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
  357. if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
  358. log "ERROR: daemon already running (pid $existing_pid). Run: fleet stop"
  359. exit 1
  360. else
  361. log "stale PID file (pid $existing_pid not alive) — clearing"
  362. rm -f "$PID_FILE"
  363. fi
  364. fi
  365. echo "$$" > "$PID_FILE"
  366. trap daemon_cleanup EXIT INT TERM HUP
  367. log "daemon start (pid $$, poll: ${POLL_INTERVAL}s, test_cmd: ${TEST_CMD:-<none>})"
  368. while true; do
  369. local ready=()
  370. for f in "$LANES_DIR"/*; do
  371. [[ -f "$f" && "$(head -n1 "$f")" == "READY" ]] && ready+=("$(basename "$f")")
  372. done
  373. if [[ ${#ready[@]} -gt 0 ]]; then
  374. for branch in "${ready[@]}"; do
  375. if land_one "$branch"; then
  376. rebase_others "$branch"
  377. fi
  378. done
  379. cmd_fleet
  380. fi
  381. local active=0
  382. for f in "$LANES_DIR"/*; do
  383. [[ -f "$f" ]] || continue
  384. local s
  385. s=$(head -n1 "$f")
  386. [[ "$s" != "LANDED" && "$s" != "FAILED" ]] && active=$((active+1))
  387. done
  388. if [[ $active -eq 0 ]]; then
  389. log "all lanes terminal — daemon exiting"
  390. cmd_fleet
  391. break
  392. fi
  393. sleep "$POLL_INTERVAL"
  394. done
  395. }
  396. case "${1:-}" in
  397. init) shift; cmd_init "$@" ;;
  398. start) shift; cmd_start "$@" ;;
  399. stop) cmd_stop ;;
  400. fleet|status) cmd_fleet ;;
  401. land) shift; cmd_land "$@" ;;
  402. revert) shift; cmd_revert "$@" ;;
  403. scrub-check) shift; cmd_scrub_check "$@" ;;
  404. ""|-h|--help)
  405. cat <<EOF
  406. fleet-ops — landing queue for concurrent Claude sessions (experimental)
  407. Usage:
  408. fleet init <name>... Create branch + worktree per name
  409. fleet start Run the daemon (writes pid to $PID_FILE)
  410. fleet stop Signal the running daemon to exit cleanly
  411. fleet fleet One-shot status view
  412. fleet land <branch> Manual land + rebase others
  413. fleet revert <branch> Revert merge commit on $BASE_BRANCH
  414. fleet scrub-check <branch> Dry-run forbidden-pattern check
  415. Config (optional): $CONFIG
  416. EOF
  417. ;;
  418. *) echo "unknown subcommand: $1" >&2; exit 1 ;;
  419. esac