fleet.sh 18 KB

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