feat(infra): kill_fleet_agent — cierre dirigido de un ejecutor de la flota (auto-kill)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 13:28:07 +02:00
parent b6ad1a3a15
commit f415dd56f5
3 changed files with 372 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
---
name: kill_fleet_agent
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: "kill_fleet_agent <sessionId|PID> [--socket <s>] [--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/<pid>.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 <sessionId>`) 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/<sessionId>.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 <socket> 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 <sessionId>`.
- **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.)
+198
View File
@@ -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 <sessionId>`). 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 <sessionId|PID> [--socket <s>] [--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 <s> 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/<pid>.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 (<pid>.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
@@ -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/<pid>.json + goals/<sid>.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