feat(launcher): improve bin/amc with launchctl, health checks, and PID recovery

Enhance the launcher script with robust process management:

Process Lifecycle:
- Use macOS launchctl to spawn server process, avoiding parent-shell
  cleanup kills that terminate AMC when Claude Code's shell exits
- Fall back to setsid+nohup on non-macOS systems
- Graceful shutdown with 5-second wait loop before force-kill

Health Checking:
- Replace simple PID file check with actual HTTP health check
- curl /api/state to verify server is truly responsive, not just alive
- Report "healthy" vs "running but not responding" states

PID Recovery:
- Recover orphan PID file from port listener (lsof -tiTCP:7400)
- Validate listener is actually amc-server (not another process on 7400)
- Auto-refresh PID file when process exists but file is stale

Logging:
- New LOG_FILE at ~/.local/share/amc/server.log
- show_recent_logs() helper for diagnostics
- Consistent log routing through launchctl submit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-25 15:01:26 -05:00
parent a7b2b3b902
commit 9cd91f6b4e

187
bin/amc
View File

@@ -1,13 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# AMC — Agent Mission Control launcher # AMC — Agent Mission Control launcher
# Usage: amc [start|stop|status] # Usage: amc [start|stop|status|logs]
set -euo pipefail set -euo pipefail
# Runtime data # Runtime data
DATA_DIR="$HOME/.local/share/amc" DATA_DIR="$HOME/.local/share/amc"
PID_FILE="$DATA_DIR/server.pid" PID_FILE="$DATA_DIR/server.pid"
LOG_FILE="$DATA_DIR/server.log"
PORT=7400 PORT=7400
LAUNCH_LABEL="com.tayloreernisse.amc-server"
# Find server relative to this script (handles symlinks) # Find server relative to this script (handles symlinks)
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
@@ -15,23 +17,142 @@ SERVER="$SCRIPT_DIR/amc-server"
cmd="${1:-start}" cmd="${1:-start}"
port_listener_pid() {
lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null | head -n 1 || true
}
is_amc_server_pid() {
local pid="${1:-}"
[ -n "$pid" ] || return 1
local cmdline
cmdline="$(ps -p "$pid" -o command= 2>/dev/null || true)"
[[ "$cmdline" == *"amc-server"* ]]
}
refresh_pid_file_from_port() {
local pid
pid="$(port_listener_pid)"
if is_amc_server_pid "$pid"; then
echo "$pid" > "$PID_FILE"
return 0
fi
return 1
}
is_running() { is_running() {
[ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
return 0
fi
refresh_pid_file_from_port
}
healthcheck_ok() {
# Require real HTTP readiness, not just a live PID.
curl -fsS "http://127.0.0.1:$PORT/api/state" >/dev/null 2>&1
}
stop_running_instance() {
local pid=""
if is_running; then
pid="$(cat "$PID_FILE")"
kill "$pid" 2>/dev/null || true
for _ in {1..50}; do
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 0.1
done
rm -f "$PID_FILE"
echo "$pid"
return 0
fi
rm -f "$PID_FILE"
return 1
}
show_recent_logs() {
local lines="${1:-120}"
if [ -f "$LOG_FILE" ]; then
echo "----- Last ${lines} lines of $LOG_FILE -----"
tail -n "$lines" "$LOG_FILE"
echo "----- End logs -----"
else
echo "No log file found at $LOG_FILE"
fi
}
launchctl_start() {
# macOS-specific: hand process ownership to launchd to avoid parent-shell cleanup kills.
launchctl remove "$LAUNCH_LABEL" >/dev/null 2>&1 || true
launchctl submit -l "$LAUNCH_LABEL" -o "$LOG_FILE" -e "$LOG_FILE" -- "$SERVER"
}
fallback_start() {
if command -v setsid >/dev/null 2>&1; then
# Detach from current session/process group so parent shell cleanup doesn't kill AMC.
nohup setsid "$SERVER" >> "$LOG_FILE" 2>&1 < /dev/null &
else
nohup "$SERVER" >> "$LOG_FILE" 2>&1 < /dev/null &
fi
echo "$!"
} }
case "$cmd" in case "$cmd" in
start) start)
if is_running; then if is_running; then
echo "AMC already running (pid $(cat "$PID_FILE"))" if healthcheck_ok; then
echo "AMC already running and healthy (pid $(cat "$PID_FILE"))"
else else
stale_pid="$(cat "$PID_FILE")"
echo "AMC process is running but unhealthy (pid $stale_pid); restarting..."
stop_running_instance >/dev/null || true
fi
fi
if ! is_running; then
existing_pid="$(port_listener_pid)"
if [ -n "$existing_pid" ] && ! is_amc_server_pid "$existing_pid"; then
existing_cmd="$(ps -p "$existing_pid" -o command= 2>/dev/null || true)"
echo "Port $PORT is already in use by pid $existing_pid ($existing_cmd)."
echo "Stop that process or choose a different AMC port."
exit 1
fi
mkdir -p "$DATA_DIR/sessions" "$DATA_DIR/events" mkdir -p "$DATA_DIR/sessions" "$DATA_DIR/events"
nohup "$SERVER" > "$DATA_DIR/server.log" 2>&1 & {
# Wait briefly for server to start and write PID echo "=== $(date -u +"%Y-%m-%dT%H:%M:%SZ") AMC start ==="
sleep 0.3 } >> "$LOG_FILE"
if is_running; then
echo "AMC started (pid $(cat "$PID_FILE"))" launched_pid=""
if [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then
if launchctl_start; then
launched_pid="launchctl:$LAUNCH_LABEL"
else else
echo "AMC started (pid $!)" echo "launchctl start failed; falling back to nohup" >> "$LOG_FILE"
launched_pid="$(fallback_start)"
fi
else
launched_pid="$(fallback_start)"
fi
# Wait up to ~5s for server process + HTTP health
for _ in {1..50}; do
if is_running && healthcheck_ok; then
break
fi
sleep 0.1
done
if is_running && healthcheck_ok; then
echo "AMC started (pid $(cat "$PID_FILE"), log $LOG_FILE)"
else
echo "AMC failed to start (launch pid $launched_pid)."
if is_running && ! healthcheck_ok; then
bad_pid="$(cat "$PID_FILE")"
echo "AMC pid $bad_pid is up but failed healthcheck (/api/state)."
fi
show_recent_logs 160
exit 1
fi fi
fi fi
# Open browser (platform-specific) # Open browser (platform-specific)
@@ -42,26 +163,58 @@ case "$cmd" in
fi fi
;; ;;
stop) stop)
if is_running; then if stopped_pid="$(stop_running_instance)"; then
pid="$(cat "$PID_FILE")" echo "AMC stopped (pid $stopped_pid)"
kill "$pid" 2>/dev/null || true
rm -f "$PID_FILE"
echo "AMC stopped (pid $pid)"
else else
echo "AMC not running" echo "AMC not running"
rm -f "$PID_FILE" fi
if [ "$(uname -s)" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then
launchctl remove "$LAUNCH_LABEL" >/dev/null 2>&1 || true
fi fi
;; ;;
status) status)
if is_running; then if is_running; then
echo "AMC running (pid $(cat "$PID_FILE"), port $PORT)" if healthcheck_ok; then
echo "AMC running (pid $(cat "$PID_FILE"), port $PORT, healthy, log $LOG_FILE)"
else
echo "AMC running but unhealthy (pid $(cat "$PID_FILE"), port $PORT, log $LOG_FILE)"
exit 1
fi
else else
echo "AMC not running" echo "AMC not running"
rm -f "$PID_FILE" rm -f "$PID_FILE"
fi fi
;; ;;
logs)
# Usage:
# amc logs
# amc logs 200
# amc logs -f
# amc logs 200 -f
lines=120
follow=false
if [ "${2:-}" = "-f" ]; then
follow=true
elif [ -n "${2:-}" ]; then
lines="$2"
fi
if [ "${3:-}" = "-f" ]; then
follow=true
fi
if [ ! -f "$LOG_FILE" ]; then
echo "No logs yet at $LOG_FILE"
exit 0
fi
if [ "$follow" = true ]; then
tail -n "$lines" -f "$LOG_FILE"
else
tail -n "$lines" "$LOG_FILE"
fi
;;
*) *)
echo "Usage: amc [start|stop|status]" echo "Usage: amc [start|stop|status|logs]"
exit 1 exit 1
;; ;;
esac esac