#!/usr/bin/env bash # kill_fleet_agent — cierre limpio y dirigido de UN ejecutor de la flota tmux. # # Dado un sessionId (o prefijo) o un PID, mata el proceso claude del ejecutor con # SIGTERM (cierre limpio) y cierra su window tmux en el socket del perfil # FleetView. Es la pieza que usa el orquestador para liberar el slot idle de cada # ejecutor en cuanto verifica que su DoD-contrato esta `met`: sin esto, los # ejecutores terminados se acumulan en reposo en la flota. # # Guards de seguridad (NO destruye a quien no debe): # - NO mata a un agente con role=orchestrator (leido de su goal.json). Matar un # orquestador por error decapitaria la flota. # - NO se mata a si mismo (la sesion que invoca la funcion): resuelve el PID de # claude actual subiendo por los ancestros de /proc y rechaza el target si # coincide. Es el equivalente dirigido de la regla "nunca pkill claude". # # Funcion IMPURA: manda SIGTERM a un proceso y cierra una window tmux. Por # defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado, # recuperable luego con `claude --resume `). Usa --dry-run para ver el # plan sin tocar nada. # # Overrides de entorno (testabilidad, no para uso normal): # FN_FLEET_SESSIONS_DIR directorio de los sessions JSON. Default ~/.claude/sessions # FN_FLEET_GOALS_DIR directorio de los goal JSON. Default ~/.claude/goals # FN_FLEET_SELF_PID fuerza el PID propio (salta la deteccion por /proc) set -euo pipefail IFS=$' \t\n' kill_fleet_agent() { local target="" socket="" dry=0 while [[ $# -gt 0 ]]; do case "$1" in --socket) shift; socket="${1:-}" ;; --dry-run) dry=1 ;; -h|--help) cat <<'USAGE' Uso: kill_fleet_agent [--socket ] [--dry-run] Cierra UN ejecutor de la flota: SIGTERM al proceso claude + kill-window de su window tmux. Resuelve el target por sessionId (exacto o por prefijo) o por PID. Guards: NO mata a un role=orchestrator ni a la sesion que invoca la funcion. Opciones: --socket Socket tmux del perfil FleetView donde vive la window. Default: $FLEET_SOCKET, o "fleet" si no esta seteada. --dry-run Imprime el plan (PID, sessionId, role, window, accion) y NO mata ni cierra nada. -h, --help Esta ayuda. Salida: exit 0 ok (o dry-run); 2 uso incorrecto / target no resuelto; 3 guard (intento de matar a un orquestador o a la sesion actual). Ejemplos: kill_fleet_agent 32945650-a4e1-472b-90c9-5b38ef60a463 # por sessionId kill_fleet_agent 32945650 --socket fleet2 # por prefijo de sessionId kill_fleet_agent 48213 --dry-run # por PID, solo ver el plan USAGE return 0 ;; --*) echo "kill_fleet_agent: opcion desconocida '$1' (usa -h)" >&2 return 2 ;; *) if [[ -z "$target" ]]; then target="$1" else echo "kill_fleet_agent: argumento extra '$1' (target ya es '$target')" >&2 return 2 fi ;; esac shift done [[ -z "$target" ]] && { echo "kill_fleet_agent: falta el target (sessionId o PID). Usa -h." >&2 return 2 } local sessions_dir="${FN_FLEET_SESSIONS_DIR:-$HOME/.claude/sessions}" local goals_dir="${FN_FLEET_GOALS_DIR:-$HOME/.claude/goals}" [[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}" command -v jq >/dev/null 2>&1 || { echo "kill_fleet_agent: jq no esta instalado (necesario para leer los JSON)" >&2 return 1 } # ----------------------------------------------------------------------- # Resolver (PID, sessionId) a partir del target. # ----------------------------------------------------------------------- local pid="" sid="" if [[ "$target" =~ ^[0-9]+$ ]]; then # target = PID. El sessionId sale de sessions/.json (si existe). pid="$target" local sfile="$sessions_dir/$pid.json" if [[ -f "$sfile" ]]; then sid="$(jq -r '.sessionId // ""' "$sfile" 2>/dev/null || true)" fi else # target = sessionId (exacto o prefijo). Buscar en sessions/*.json el JSON # cuyo .sessionId case; el nombre del archivo (.json) da el PID. local f base candidate_sid for f in "$sessions_dir"/*.json; do [[ -f "$f" ]] || continue candidate_sid="$(jq -r '.sessionId // ""' "$f" 2>/dev/null || true)" [[ -z "$candidate_sid" ]] && continue if [[ "$candidate_sid" == "$target" || "$candidate_sid" == "$target"* ]]; then base="$(basename "$f" .json)" pid="$base" sid="$candidate_sid" break fi done fi [[ -z "$pid" ]] && { echo "kill_fleet_agent: no se pudo resolver el target '$target' a un PID (sessions en $sessions_dir)" >&2 return 2 } # ----------------------------------------------------------------------- # Guard 1 — anti-self: no matar a la sesion que invoca la funcion. # ----------------------------------------------------------------------- local self_pid="${FN_FLEET_SELF_PID:-}" if [[ -z "$self_pid" ]]; then local walk="$$" guard=0 comm while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)" if [[ "$comm" == "claude" ]]; then self_pid="$walk" break fi walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)" guard=$((guard + 1)) [[ "$guard" -gt 64 ]] && break done fi if [[ -n "$self_pid" && "$pid" == "$self_pid" ]]; then echo "kill_fleet_agent: REHUSADO — el target (PID $pid) es la sesion actual. No me suicido." >&2 return 3 fi # ----------------------------------------------------------------------- # Guard 2 — anti-orquestador: no matar a un role=orchestrator. # ----------------------------------------------------------------------- local role="" if [[ -n "$sid" ]]; then local gfile="$goals_dir/$sid.json" [[ -f "$gfile" ]] && role="$(jq -r '.role // ""' "$gfile" 2>/dev/null || true)" fi if [[ "$role" == "orchestrator" ]]; then echo "kill_fleet_agent: REHUSADO — el target (sessionId ${sid:-?}, PID $pid) tiene role=orchestrator. No se mata al orquestador." >&2 return 3 fi # ----------------------------------------------------------------------- # Resolver la window tmux del PID en el socket (pane_pid == claude por el # `exec claude` de spawn_fleet_agent). Best-effort: vacio si no hay socket. # ----------------------------------------------------------------------- local window="" if command -v tmux >/dev/null 2>&1; then window="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id}' 2>/dev/null \ | awk -v p="$pid" '$1==p {print $2; exit}' || true)" fi # ----------------------------------------------------------------------- # Plan (se imprime siempre). # ----------------------------------------------------------------------- echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)}" if [[ "$dry" -eq 1 ]]; then echo "DRY-RUN: no se ha matado el proceso ni cerrado la window." return 0 fi # ----------------------------------------------------------------------- # Ejecutar: SIGTERM al claude (cierre limpio) + kill-window (idempotente). # ----------------------------------------------------------------------- if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true echo "kill_fleet_agent: SIGTERM enviado a claude PID $pid." else echo "kill_fleet_agent: PID $pid ya no esta vivo (nada que matar)." fi if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true echo "kill_fleet_agent: window $window cerrada en el socket $socket." fi return 0 } # Permitir ejecutar el archivo directamente (no solo como funcion sourced). if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then kill_fleet_agent "$@" fi