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:
@@ -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.)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user