From f415dd56f520d2d145105d0e4170350cd0205232 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 21 Jun 2026 13:28:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(infra):=20kill=5Ffleet=5Fagent=20=E2=80=94?= =?UTF-8?q?=20cierre=20dirigido=20de=20un=20ejecutor=20de=20la=20flota=20(?= =?UTF-8?q?auto-kill)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra UN ejecutor por sessionId (exacto/prefijo) o PID: SIGTERM al proceso claude (cierre limpio, recuperable con --resume) + kill-window de su window tmux. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Guards: NO mata a un role=orchestrator (leido del goal.json) ni a la sesion que invoca (PID propio por ancestros /proc). --dry-run para inspeccionar sin tocar nada. Overrides FN_FLEET_* para tests. Tag grupo orchestration. Tests: 17 asserts (golden por sessionId/PID/prefijo, guards orchestrator/self rc=3, errores rc=2). Co-Authored-By: Claude Opus 4.8 (1M context) --- bash/functions/infra/kill_fleet_agent.md | 65 ++++++ bash/functions/infra/kill_fleet_agent.sh | 198 ++++++++++++++++++ bash/functions/infra/kill_fleet_agent_test.sh | 109 ++++++++++ 3 files changed, 372 insertions(+) create mode 100644 bash/functions/infra/kill_fleet_agent.md create mode 100644 bash/functions/infra/kill_fleet_agent.sh create mode 100644 bash/functions/infra/kill_fleet_agent_test.sh diff --git a/bash/functions/infra/kill_fleet_agent.md b/bash/functions/infra/kill_fleet_agent.md new file mode 100644 index 00000000..f5fab303 --- /dev/null +++ b/bash/functions/infra/kill_fleet_agent.md @@ -0,0 +1,65 @@ +--- +name: kill_fleet_agent +kind: function +lang: bash +domain: infra +version: 1.0.0 +purity: impure +signature: "kill_fleet_agent [--socket ] [--dry-run]" +description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux (kill-window) en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json) ni a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc). Por defecto EJECUTA; --dry-run imprime el plan sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota." +tags: [fleet, claude-fleet, orchestration, tmux, kill, infra] +uses_functions: [] +uses_types: [] +error_type: error_go_core +file_path: "bash/functions/infra/kill_fleet_agent.sh" +tested: true +tests: + - "golden: ejecutor por sessionId, PID y prefijo se resuelve y dry-run imprime el plan" + - "guard: matar un role=orchestrator devuelve rc=3 y se niega" + - "guard: matar la sesion actual (self) devuelve rc=3 y se niega" + - "error: target no resuelto rc=2; sin target rc=2" +test_file_path: "bash/functions/infra/kill_fleet_agent_test.sh" +params: + - name: target + desc: "Primer arg posicional: sessionId del ejecutor (exacto o prefijo) o su PID (todo digitos). Por PID se lee sessions/.json para el sessionId; por sessionId se busca en sessions/*.json el que case y su archivo da el PID." + - name: --socket + desc: "Socket tmux del perfil FleetView donde vive la window. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada." + - name: --dry-run + desc: "Imprime el plan (PID, sessionId, role, window, accion) y NO mata el proceso ni cierra la window. Sin esto, ejecuta." +output: "Imprime una linea de plan con PID, sessionId, role, socket y window resueltos, seguida de la accion ejecutada (SIGTERM + kill-window) o, con --dry-run, de DRY-RUN. Exit 0 ok/dry-run; 2 uso incorrecto o target no resuelto a PID; 3 guard (target es un orchestrator o la sesion actual)." +--- + +# kill_fleet_agent + +Cierra de forma dirigida UN ejecutor de la flota tmux: SIGTERM al proceso `claude` (cierre limpio, recuperable con `claude --resume `) más `kill-window` de su window en el socket del perfil FleetView. Es la pieza que el orquestador usa para **liberar el slot idle** de cada ejecutor en cuanto verifica que su DoD-contrato está `met` — sin esto, los ejecutores terminados se acumulan en reposo en la flota. + +## Ejemplo + +```bash +# Cerrar un ejecutor por sessionId (el orquestador lo llama tras verificar `met`): +./fn run kill_fleet_agent 32945650-a4e1-472b-90c9-5b38ef60a463 --socket "$FLEET_SOCKET" + +# Por prefijo de sessionId, en el socket por defecto ($FLEET_SOCKET o "fleet"): +./fn run kill_fleet_agent 32945650 + +# Ver el plan sin matar nada (PID, sessionId, role, window, accion): +./fn run kill_fleet_agent 48213 --dry-run +``` + +## Cuando usarla + +Úsala desde el modo orquestador justo después de que el verificador independiente devuelva `met` sobre un ejecutor: ciérralo para que no quede ocupando un slot idle en la flota. Resuelve el target por sessionId (exacto o prefijo) o por PID, comprueba los guards y manda SIGTERM + cierra la window. Es el cierre dirigido a **un** agente; para reiniciar/parar **toda** la flota usa `reboot_all_claudes` (con `--exclude-current`). Nunca uses `pkill`/`killall claude` (te matas a ti mismo, el orquestador). + +## Gotchas + +- **Impura y destructiva**: manda SIGTERM y cierra una window tmux. Por defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado `met`); usa `--dry-run` para inspeccionar antes. +- **Guard anti-orquestador**: si el goal.json del target tiene `role=orchestrator`, rehúsa con exit 3. Evita decapitar la flota por error. El `role` se lee de `~/.claude/goals/.json` (lo escribe `mark_claude_role`). +- **Guard anti-self**: resuelve el PID de `claude` de la sesión actual subiendo por los ancestros de `/proc`; si el target coincide, rehúsa con exit 3 ("No me suicido"). Es el equivalente dirigido de la regla "nunca `pkill claude`". +- **Resolución de la window**: usa `tmux -L list-panes -a` y casa `pane_pid == PID`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla). +- **SIGTERM, no SIGKILL**: cierre limpio para que Claude Code persista su sesión; el trabajo se puede retomar con `claude --resume `. +- **Requiere `jq`** para leer los JSON de sessions/goals. +- **Overrides de entorno solo para tests**: `FN_FLEET_SESSIONS_DIR`, `FN_FLEET_GOALS_DIR` y `FN_FLEET_SELF_PID` redirigen los directorios y fuerzan el PID propio; no usarlos en operación normal. + +## Capability growth log + +(v1.0.0 — sin cambios todavía.) diff --git a/bash/functions/infra/kill_fleet_agent.sh b/bash/functions/infra/kill_fleet_agent.sh new file mode 100644 index 00000000..979c3bc9 --- /dev/null +++ b/bash/functions/infra/kill_fleet_agent.sh @@ -0,0 +1,198 @@ +#!/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 diff --git a/bash/functions/infra/kill_fleet_agent_test.sh b/bash/functions/infra/kill_fleet_agent_test.sh new file mode 100644 index 00000000..923bcb4d --- /dev/null +++ b/bash/functions/infra/kill_fleet_agent_test.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Tests para kill_fleet_agent. Usa fixtures en dirs temporales (FN_FLEET_*) y +# --dry-run para no matar procesos ni cerrar windows reales. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/kill_fleet_agent.sh" + +PASS=0 +FAIL=0 + +assert_contains() { + local test_name="$1" needle="$2" haystack="$3" + if echo "$haystack" | grep -qF "$needle"; then + echo "PASS: $test_name" + PASS=$((PASS+1)) + else + echo "FAIL: $test_name — expected to contain '$needle'" + echo " got: $haystack" + FAIL=$((FAIL+1)) + fi +} + +assert_rc() { + local test_name="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + echo "PASS: $test_name (rc=$actual)" + PASS=$((PASS+1)) + else + echo "FAIL: $test_name — expected rc=$expected, got rc=$actual" + FAIL=$((FAIL+1)) + fi +} + +# --- Fixtures: sessions/.json + goals/.json en dirs temporales --- +TMP="$(mktemp -d)" +SESS="$TMP/sessions" +GOALS="$TMP/goals" +mkdir -p "$SESS" "$GOALS" + +# Ejecutor: PID 4242, sessionId executor-aaa-111, role=executor. +echo '{"sessionId":"executor-aaa-111","cwd":"/tmp/x"}' > "$SESS/4242.json" +echo '{"goal":"hacer X","role":"executor","dod_contract":"golden..."}' > "$GOALS/executor-aaa-111.json" + +# Orquestador: PID 5555, sessionId orchestrator-bbb-222, role=orchestrator. +echo '{"sessionId":"orchestrator-bbb-222","cwd":"/tmp/y"}' > "$SESS/5555.json" +echo '{"goal":"orquestar","role":"orchestrator"}' > "$GOALS/orchestrator-bbb-222.json" + +trap 'rm -rf "$TMP"' EXIT + +export FN_FLEET_SESSIONS_DIR="$SESS" +export FN_FLEET_GOALS_DIR="$GOALS" +# Forzar self_pid a un valor que NO colisione con los fixtures (salvo el test self). +export FN_FLEET_SELF_PID=999999 + +# --- Test 1 (golden): resolver ejecutor por sessionId, dry-run imprime plan --- +set +e +out=$(kill_fleet_agent executor-aaa-111 --socket nope --dry-run 2>&1); rc=$? +set -e +assert_rc "golden: ejecutor por sessionId sale 0" 0 "$rc" +assert_contains "golden: plan muestra el PID resuelto" "PID: 4242" "$out" +assert_contains "golden: plan muestra el sessionId" "executor-aaa-111" "$out" +assert_contains "golden: plan muestra role executor" "role: executor" "$out" +assert_contains "golden: dry-run no mata" "DRY-RUN" "$out" + +# --- Test 2 (golden por PID + prefijo de sessionId) --- +set +e +out=$(kill_fleet_agent 4242 --dry-run 2>&1); rc=$? +set -e +assert_rc "golden: target por PID sale 0" 0 "$rc" +assert_contains "golden: PID resuelve su sessionId" "executor-aaa-111" "$out" + +set +e +out=$(kill_fleet_agent executor-aaa --dry-run 2>&1); rc=$? +set -e +assert_rc "edge: prefijo de sessionId resuelve" 0 "$rc" +assert_contains "edge: prefijo resuelve al PID 4242" "PID: 4242" "$out" + +# --- Test 3 (EDGE guard role): negar matar a un orchestrator --- +set +e +out=$(kill_fleet_agent orchestrator-bbb-222 --dry-run 2>&1); rc=$? +set -e +assert_rc "guard: matar orchestrator devuelve rc=3" 3 "$rc" +assert_contains "guard: mensaje menciona role=orchestrator" "role=orchestrator" "$out" + +# --- Test 4 (EDGE guard self): negar matar a la sesion actual --- +set +e +out=$(FN_FLEET_SELF_PID=4242 kill_fleet_agent executor-aaa-111 --dry-run 2>&1); rc=$? +set -e +assert_rc "guard: matar self devuelve rc=3" 3 "$rc" +assert_contains "guard: mensaje self menciona no suicidarse" "No me suicido" "$out" + +# --- Test 5 (ERROR): target no resuelto a un PID --- +set +e +out=$(kill_fleet_agent sesion-inexistente-zzz --dry-run 2>&1); rc=$? +set -e +assert_rc "error: target inexistente devuelve rc=2" 2 "$rc" +assert_contains "error: mensaje de no resuelto" "no se pudo resolver" "$out" + +# --- Test 6 (ERROR): falta el target --- +set +e +out=$(kill_fleet_agent --dry-run 2>&1); rc=$? +set -e +assert_rc "error: sin target devuelve rc=2" 2 "$rc" +assert_contains "error: mensaje falta target" "falta el target" "$out" + +echo "---" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] || exit 1