diff --git a/bash/functions/infra/kill_fleet_agent.md b/bash/functions/infra/kill_fleet_agent.md index f5fab303..4ff49a5c 100644 --- a/bash/functions/infra/kill_fleet_agent.md +++ b/bash/functions/infra/kill_fleet_agent.md @@ -3,10 +3,10 @@ name: kill_fleet_agent kind: function lang: bash domain: infra -version: 1.0.0 +version: 1.1.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." +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 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. Tres guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json); NUNCA a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc); y NUNCA cierra la window que aloja la TUI fleetview o la window 'console' con kill-window (eso se llevaria el panel de control por delante) — en ese caso cierra SOLO el pane del target con kill-pane y preserva la TUI. Por defecto EJECUTA; --dry-run imprime el plan (incluida la accion kill-pane vs kill-window) 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: [] @@ -17,6 +17,7 @@ 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" + - "guard3: predicado _fleet_window_hosts_tui detecta window 'console' o pane fleetview" - "error: target no resuelto rc=2; sin target rc=2" test_file_path: "bash/functions/infra/kill_fleet_agent_test.sh" params: @@ -55,11 +56,13 @@ Cierra de forma dirigida UN ejecutor de la flota tmux: SIGTERM al proceso `claud - **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). +- **Guard 3 — anti-TUI/console (no decapitar el panel)**: antes de cerrar nada, comprueba si la window del target **aloja la TUI fleetview** (algún pane corre el binario `fleetview`) o se llama **`console`**. El layout FleetView mete la TUI y un Claude en la misma window `console`, y los focus-swaps (`join-pane`) pueden meter al ejecutor target en esa window; un `kill-window` ahí se llevaría la TUI por delante (causa del fallo descrito en `fleetview` v0.4.3). En ese caso la función NO usa `kill-window`: manda el SIGTERM al claude y cierra **solo su pane** con `kill-pane`, preservando el pane de la TUI. El plan (y el `--dry-run`) lo refleja como `accion: kill-pane … (aloja la TUI/console)` vs `accion: kill-window …`. El predicado es la función interna `_fleet_window_hosts_tui` (testeada). Se mantiene inline (no función propia del registry) por estar acoplada a este flujo y para no dejar una capacidad huérfana (KISS). +- **Resolución de la window y el pane**: usa `tmux -L list-panes -a` y casa `pane_pid == PID`, capturando `window_id`, `pane_id` y `window_name`. 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.) +- v1.1.0 (2026-06-24) — **Guard 3 anti-TUI/console** (elimina un gotcha conocido). Antes, si un focus-swap metía al ejecutor target en la window `console` (la que aloja la TUI fleetview), `kill-window` cerraba la TUI por error. Ahora, cuando la window del target aloja la TUI (pane `fleetview`) o se llama `console`, se cierra solo el pane del target con `kill-pane` y la TUI sobrevive; el resto de windows siguen cerrándose con `kill-window`. Predicado interno `_fleet_window_hosts_tui` con tests. Es la causa raíz que complementa el auto-respawn de la TUI (`supervise_fleetview_tui`). +- v1.0.0 — versión inicial. diff --git a/bash/functions/infra/kill_fleet_agent.sh b/bash/functions/infra/kill_fleet_agent.sh index 979c3bc9..97f8929d 100644 --- a/bash/functions/infra/kill_fleet_agent.sh +++ b/bash/functions/infra/kill_fleet_agent.sh @@ -26,6 +26,25 @@ set -euo pipefail IFS=$' \t\n' +# Predicado (puro respecto a tmux): dada una window — su nombre y el texto de sus +# panes en formato " " (una linea por pane) — +# decide si esa window ALOJA la TUI fleetview o es la window 'console' del perfil. +# Si es asi, cerrar la window entera con kill-window se llevaria la TUI por +# delante; el caller debe cerrar solo el pane del target con kill-pane. +# - Nombre de window 'console' = la window del panel FleetView por convencion +# del launcher (y a donde el focus-swap ancla la TUI, ver fleetview v0.4.3). +# - Algun pane corre el binario 'fleetview' (pane_current_command) = la TUI +# vive ahi aunque la window se haya renombrado. +# Devuelve 0 si aloja la TUI/console, 1 si no. +_fleet_window_hosts_tui() { + local window_name="${1:-}" panes_text="${2:-}" + [[ "$window_name" == "console" ]] && return 0 + if printf '%s\n' "$panes_text" | awk '{print $2}' | grep -qx 'fleetview'; then + return 0 + fi + return 1 +} + kill_fleet_agent() { local target="" socket="" dry=0 @@ -155,27 +174,65 @@ USAGE 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. + # Resolver la window tmux Y el pane del PID en el socket (pane_pid == claude + # por el `exec claude` de spawn_fleet_agent). Capturamos window_id, pane_id y + # window_name juntos. Best-effort: vacio si no hay socket. # ----------------------------------------------------------------------- - local window="" + local window="" pane="" wname="" 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)" + local line + line="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id} #{pane_id} #{window_name}' 2>/dev/null \ + | awk -v p="$pid" '$1==p {print $2, $3, $4; exit}' || true)" + if [[ -n "$line" ]]; then + window="$(awk '{print $1}' <<<"$line")" + pane="$(awk '{print $2}' <<<"$line")" + wname="$(awk '{print $3}' <<<"$line")" + fi + fi + + # ----------------------------------------------------------------------- + # Guard 3 — anti-TUI/console: si la window del target aloja la TUI fleetview + # o es la window 'console' del perfil, NO cerramos la window entera (eso se + # llevaria la TUI), sino solo el pane del target con kill-pane. El layout + # FleetView mete la TUI y un Claude en la misma window 'console', y los + # focus-swaps (join-pane) pueden meter al ejecutor target en esa window. + # ----------------------------------------------------------------------- + local hosts_tui=0 + if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then + local panes_text + panes_text="$(tmux -L "$socket" list-panes -t "$window" -F '#{pane_pid} #{pane_current_command}' 2>/dev/null || true)" + if _fleet_window_hosts_tui "$wname" "$panes_text"; then + hosts_tui=1 + fi + fi + + # Accion sobre la window/pane segun lo resuelto y el Guard 3. + local action + if [[ -z "$window" ]]; then + action="solo SIGTERM (window no resuelta)" + elif [[ "$hosts_tui" -eq 1 ]]; then + if [[ -n "$pane" ]]; then + action="kill-pane $pane (window '${wname:-$window}' aloja la TUI/console; se preserva la TUI)" + else + action="solo SIGTERM (window '${wname:-$window}' aloja la TUI y no se resolvio el pane; window preservada)" + fi + else + action="kill-window $window" fi # ----------------------------------------------------------------------- # Plan (se imprime siempre). # ----------------------------------------------------------------------- - echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)}" + echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)} pane: ${pane:-?} accion: $action" if [[ "$dry" -eq 1 ]]; then - echo "DRY-RUN: no se ha matado el proceso ni cerrado la window." + echo "DRY-RUN: no se ha matado el proceso ni cerrado nada." return 0 fi # ----------------------------------------------------------------------- - # Ejecutar: SIGTERM al claude (cierre limpio) + kill-window (idempotente). + # Ejecutar: SIGTERM al claude (cierre limpio) + cierre de pane/window segun + # el Guard 3 (idempotente). # ----------------------------------------------------------------------- if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true @@ -185,8 +242,17 @@ USAGE 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." + if [[ "$hosts_tui" -eq 1 ]]; then + if [[ -n "$pane" ]]; then + tmux -L "$socket" kill-pane -t "$pane" 2>/dev/null || true + echo "kill_fleet_agent: pane $pane cerrado (window '${wname:-$window}' aloja la TUI; window preservada)." + else + echo "kill_fleet_agent: window '${wname:-$window}' aloja la TUI pero no se resolvio el pane; solo SIGTERM (window preservada)." + fi + else + tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true + echo "kill_fleet_agent: window $window cerrada en el socket $socket." + fi fi return 0 diff --git a/bash/functions/infra/kill_fleet_agent_test.sh b/bash/functions/infra/kill_fleet_agent_test.sh index 923bcb4d..9a51992e 100644 --- a/bash/functions/infra/kill_fleet_agent_test.sh +++ b/bash/functions/infra/kill_fleet_agent_test.sh @@ -104,6 +104,24 @@ set -e assert_rc "error: sin target devuelve rc=2" 2 "$rc" assert_contains "error: mensaje falta target" "falta el target" "$out" +# --- Test 7 (Guard 3 predicado): _fleet_window_hosts_tui --- +# La window 'console' SIEMPRE se considera que aloja la TUI (no se cierra entera). +assert_predicate() { + local test_name="$1" expected="$2"; shift 2 + set +e + _fleet_window_hosts_tui "$@"; local rc=$? + set -e + assert_rc "$test_name" "$expected" "$rc" +} +# Nombre de window 'console' -> aloja TUI (rc 0), aunque ningun pane sea fleetview. +assert_predicate "guard3: window 'console' aloja la TUI" 0 "console" $'1234 claude\n5678 bash' +# Algun pane corre 'fleetview' -> aloja TUI (rc 0), aunque la window no sea console. +assert_predicate "guard3: pane fleetview aloja la TUI" 0 "claude" $'1111 bash\n2222 fleetview' +# Ni console ni fleetview -> NO aloja la TUI (rc 1): kill-window normal. +assert_predicate "guard3: window normal no aloja la TUI" 1 "claude" $'3333 claude\n4444 bash' +# Substring que contiene 'fleetview' pero no es el comando exacto -> NO matchea (grep -qx). +assert_predicate "guard3: comando 'fleetviewer' no falsea positivo" 1 "work" $'7777 fleetviewer' + echo "---" echo "Results: $PASS passed, $FAIL failed" [[ $FAIL -eq 0 ]] || exit 1 diff --git a/bash/functions/infra/launch_fleetclaude.md b/bash/functions/infra/launch_fleetclaude.md index 6bc5ccb2..5e0ba0cd 100644 --- a/bash/functions/infra/launch_fleetclaude.md +++ b/bash/functions/infra/launch_fleetclaude.md @@ -3,10 +3,10 @@ name: launch_fleetclaude kind: function lang: bash domain: infra -version: "1.4.0" +version: "1.5.0" purity: impure signature: "launch_fleetclaude [--cwd ] [--bin ] [--session ] [--reuse] [--cols ]" -description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks." +description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks." tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher] params: - name: --cwd @@ -20,7 +20,8 @@ params: - name: --cols desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40." output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito." -uses_functions: [] +uses_functions: + - supervise_fleetview_tui_bash_infra uses_types: [] returns: [] returns_optional: false @@ -83,10 +84,20 @@ al retomar el trabajo en el repo `fn_registry`. TTY, reutiliza la terminal actual con `exec tmux attach`. - **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre. -- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi - que al terminar el proceso el pane se cierra en vez de dejar una shell zombie - colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una - shell interactiva a proposito (para que veas el mensaje y puedas compilar). +- **TUI bajo supervisor (auto-respawn)**: el pane izquierdo NO corre un + `exec fleetview` de una sola vida, sino `supervise_fleetview_tui` (bucle que + relanza la TUI si muere por crash/panic/kill). Asi el panel de control nunca se + pierde por un fallo puntual. El supervisor para limpio con su sentinel + (`touch ~/.claude/fleet/tui_stop_` y deja salir la TUI) o se rinde si la + TUI entra en crash-loop; en ambos casos el pane cae a una shell viva (no se + cierra solo) para inspeccionar. Es la mitad "auto-recuperacion" del par de + fixes que blindan FleetView; la otra es el Guard 3 anti-TUI/console de + `kill_fleet_agent` (la causa raiz del cierre accidental). Si el script del + supervisor no estuviera en disco, cae al `exec fleetview` clasico. +- **`exec` en los demas panes**: `claude` (orquestador e idle) se lanza con + `exec`, asi que al terminar el proceso el pane se cierra en vez de dejar una + shell zombie. Excepcion: el fallback cuando `fleetview` no esta compilado deja + una shell interactiva a proposito (para que veas el mensaje y puedas compilar). - **Requiere fleetview compilado**: el default `--bin` apunta a `/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en @@ -113,6 +124,13 @@ al retomar el trabajo en el repo `fn_registry`. ## Capability growth log +- v1.5.0 (2026-06-24) — **auto-respawn de la TUI**. El pane izquierdo ya no corre + `exec fleetview` (una sola vida), sino el bucle supervisor + `supervise_fleetview_tui`, que relanza la TUI si muere (crash/panic/kill de su + proceso o pane). Asi el panel de control NUNCA se pierde por un fallo puntual. + Parada voluntaria via sentinel; crash-loop guard para no relanzar en bucle + cerrado. Complementa el Guard 3 anti-TUI/console de `kill_fleet_agent` (causa + raiz del cierre accidental). Nueva dependencia: `supervise_fleetview_tui_bash_infra`. - v1.4.0 (2026-06-18) — **perfiles multiples**. Socket+sesion tmux ya no son el fijo `fleet`: cada perfil tiene los suyos (mismo nombre). Sin `--session`/ `--reuse`, cada invocacion abre el primer perfil libre (`fleet`, `fleet2`, ...), diff --git a/bash/functions/infra/launch_fleetclaude.sh b/bash/functions/infra/launch_fleetclaude.sh index 6c5281a2..67b25916 100644 --- a/bash/functions/infra/launch_fleetclaude.sh +++ b/bash/functions/infra/launch_fleetclaude.sh @@ -170,7 +170,22 @@ USAGE envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")" local left_cmd if [[ -x "$bin" ]]; then - left_cmd="$envpfx exec $(printf '%q' "$bin")" + # NO un `exec fleetview` de una sola vida: lo envolvemos en el bucle + # supervisor supervise_fleetview_tui, que relanza la TUI si muere (crash, + # panic, kill de su proceso o de su pane). Asi el panel de control de la + # flota NUNCA se pierde por un fallo puntual. El supervisor para limpio + # con su sentinel (touch ~/.claude/fleet/tui_stop_) o se rinde si + # la TUI entra en crash-loop; en ambos casos cae a una shell viva. + local sup="$repo_root/bash/functions/infra/supervise_fleetview_tui.sh" + if [[ -f "$sup" ]]; then + # bash (no exec): al volver el supervisor (sentinel o crash-loop) + # caemos a una shell viva para que el mensaje siga visible y se pueda + # inspeccionar/relanzar. El env aplica al supervisor y a su hijo TUI. + left_cmd="$envpfx bash $(printf '%q' "$sup") --bin $(printf '%q' "$bin") --socket $(printf '%q' "$session"); exec \"\$SHELL\"" + else + # Fallback si falta el supervisor en disco: comportamiento clasico. + left_cmd="$envpfx exec $(printf '%q' "$bin")" + fi else # Fallback claro: instruye como compilar la TUI y deja una shell viva. left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\"" diff --git a/bash/functions/infra/supervise_fleetview_tui.md b/bash/functions/infra/supervise_fleetview_tui.md new file mode 100644 index 00000000..4bd03b89 --- /dev/null +++ b/bash/functions/infra/supervise_fleetview_tui.md @@ -0,0 +1,67 @@ +--- +name: supervise_fleetview_tui +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "supervise_fleetview_tui --bin [--socket ] [--sentinel ] [--backoff ] [--min-uptime ] [--max-fast-exits ]" +description: "Bucle supervisor que mantiene viva la TUI fleetview: lanza el binario y, si sale (crash, panic, kill de su proceso o pane), lo relanza tras un backoff, para que el panel de control de la flota NUNCA se pierda por un fallo puntual. Es la pieza que hace resiliente al pane izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude). Dos valvulas de escape evitan el respawn infinito: un fichero centinela (touch => parada voluntaria al siguiente ciclo) y un crash-loop guard (si la TUI sale demasiado rapido muchas veces seguidas, el supervisor se rinde con rc=3 en vez de quemar CPU relanzando un binario roto)." +tags: [fleet, claude-fleet, orchestration, fleetview, tui, supervisor, resilience, infra] +uses_functions: [] +uses_types: [] +error_type: error_go_core +file_path: "bash/functions/infra/supervise_fleetview_tui.sh" +tested: true +tests: + - "golden: tras salir el binario, el supervisor lo relanza (respawn observable)" + - "sentinel: tocar el fichero centinela para el bucle limpio (rc=0) y lo consume" + - "crash-loop: salidas rapidas seguidas >= max_fast_exits hacen que se rinda (rc=3)" + - "error: sin --bin rc=1; binario no ejecutable rc=1" +test_file_path: "bash/functions/infra/supervise_fleetview_tui_test.sh" +params: + - name: --bin + desc: "Ruta al binario fleetview a supervisar. Obligatorio. Si no es ejecutable, sale con rc=1 con instruccion de compilado." + - name: --socket + desc: "Socket del perfil FleetView. Solo fija el nombre del sentinel por defecto. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada." + - name: --sentinel + desc: "Ruta del fichero centinela de parada voluntaria. Si existe tras una salida de la TUI, se borra y el bucle termina. Default: $HOME/.claude/fleet/tui_stop_." + - name: --backoff + desc: "Segundos de espera antes de relanzar la TUI tras una salida. Default: 1." + - name: --min-uptime + desc: "Umbral en segundos para considerar una salida 'rapida' (sospecha de crash-loop). Un arranque que dura >= este valor resetea el contador. Default: 2." + - name: --max-fast-exits + desc: "Numero de salidas rapidas seguidas tras las que el supervisor se rinde (crash-loop guard) en vez de seguir relanzando. Default: 5." +output: "No retorna valor; corre indefinidamente relanzando la TUI. Sale 0 ante parada voluntaria (sentinel), 1 ante uso incorrecto / binario no ejecutable, 3 cuando el crash-loop guard se rinde. Imprime una linea por cada relanzamiento o parada." +--- + +# supervise_fleetview_tui + +Bucle supervisor de la TUI `fleetview`. Corre el binario y, cada vez que sale (crash, panic, `kill` de su proceso, cierre de su pane), lo **relanza** tras un pequeño backoff. Hace que el panel de control de la flota — el pane izquierdo de la sesión tmux FleetView — **nunca se pierda** por un fallo puntual. `launch_fleetclaude` lo usa como comando del pane izquierdo en vez de un `exec fleetview` de una sola vida. + +## Ejemplo + +```bash +# Como lo invoca el launcher en el pane izquierdo (relanza la TUI si muere): +FLEET_SOCKET=fleet bash bash/functions/infra/supervise_fleetview_tui.sh \ + --bin apps/fleetview/fleetview --socket fleet + +# Pararlo voluntariamente desde otra terminal: tocar el sentinel y dejar salir la TUI. +touch ~/.claude/fleet/tui_stop_fleet +``` + +## Cuando usarla + +Úsala como wrapper del binario `fleetview` siempre que quieras que la TUI sobreviva a un crash o a un `kill` accidental de su proceso/pane (p. ej. un `kill_fleet_agent` que cierre la window que la aloja). Es la mitad "auto-recuperación" del par de fixes que blindan FleetView; la otra mitad es el Guard 3 anti-TUI/console de `kill_fleet_agent` (la causa raíz). No la uses para supervisar Claudes (esos se relanzan con `claude --resume`, no en bucle ciego). + +## Gotchas + +- **Impura y de larga duración**: corre indefinidamente. Está pensada para vivir en un pane tmux con TTY, no como systemd service (la TUI necesita PTY; el watcher de fleetview sí es systemd `Restart=always`). +- **Crash-loop guard**: si la TUI sale en menos de `--min-uptime` segundos, `--max-fast-exits` veces seguidas, el supervisor se **rinde** (rc=3) en vez de relanzar para siempre un binario roto. Ajusta los umbrales si tu arranque es legítimamente lento. +- **Sentinel = única parada voluntaria limpia**: `touch ` y deja que la TUI salga; al siguiente ciclo el supervisor ve el fichero, lo borra y termina. Sin sentinel, **relanza siempre** (es el objetivo: que no se pierda). Un sentinel huérfano de una sesión previa se limpia al arrancar para no parar de inmediato. +- **El sentinel por defecto depende del socket**: `~/.claude/fleet/tui_stop_`. Dos perfiles (`fleet`, `fleet2`) tienen sentinels distintos, así parar uno no para el otro. +- **No supervisa Claudes**: su contrato es solo la TUI. Relanzar un Claude en bucle ciego perdería su sesión; los Claudes se recuperan con `claude --resume`. + +## Capability growth log + +(v1.0.0 — sin cambios todavía.) diff --git a/bash/functions/infra/supervise_fleetview_tui.sh b/bash/functions/infra/supervise_fleetview_tui.sh new file mode 100644 index 00000000..a962cc4f --- /dev/null +++ b/bash/functions/infra/supervise_fleetview_tui.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# supervise_fleetview_tui — bucle supervisor que mantiene viva la TUI fleetview. +# +# Lanza el binario fleetview y, si sale (crash, panic, kill de su proceso o de su +# pane), lo relanza tras un pequeno backoff. Asi el panel de control de la flota +# NUNCA se pierde por un fallo puntual: es la pieza que hace resiliente al pane +# izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude). +# +# Dos valvulas de escape para no hacer respawn infinito: +# - Sentinel file: si tras una salida existe el fichero centinela, se borra y +# el bucle termina (parada voluntaria solicitada por el usuario). El default +# es $HOME/.claude/fleet/tui_stop_; pararla a mano: `touch ` +# y dejar que la TUI salga (o matar su proceso). +# - Crash-loop guard: si la TUI sale demasiado rapido (uptime < min_uptime +# segundos) muchas veces seguidas (>= max_fast_exits), el supervisor se rinde +# y devuelve != 0, para no quemar CPU relanzando un binario roto en caliente. +# Un arranque que dura >= min_uptime resetea el contador. +# +# Funcion IMPURA: lanza un proceso en bucle y lee/escribe un fichero centinela. +# +# Overrides de entorno (testabilidad, no para uso normal): +# FLEET_SOCKET socket del perfil; fija el nombre del sentinel por defecto. +set -euo pipefail +IFS=$' \t\n' + +supervise_fleetview_tui() { + local bin="" socket="" sentinel="" backoff=1 min_uptime=2 max_fast_exits=5 + + while [[ $# -gt 0 ]]; do + case "$1" in + --bin) shift; bin="${1:-}" ;; + --socket) shift; socket="${1:-}" ;; + --sentinel) shift; sentinel="${1:-}" ;; + --backoff) shift; backoff="${1:-1}" ;; + --min-uptime) shift; min_uptime="${1:-2}" ;; + --max-fast-exits) shift; max_fast_exits="${1:-5}" ;; + -h|--help) + cat <<'USAGE' +Uso: supervise_fleetview_tui --bin [opciones] + +Bucle supervisor: corre el binario fleetview y lo relanza si sale, para que el +panel de la flota nunca se pierda por un crash/kill puntual. + +Opciones: + --bin Ruta al binario fleetview (obligatorio). + --socket Socket del perfil FleetView. Default: $FLEET_SOCKET o "fleet". + --sentinel Fichero centinela de parada voluntaria. + Default: $HOME/.claude/fleet/tui_stop_. + --backoff Segundos de espera antes de relanzar. Default: 1. + --min-uptime Umbral (s) para considerar una salida "rapida". Default: 2. + --max-fast-exits Salidas rapidas seguidas tras las que el supervisor se + rinde (crash-loop guard). Default: 5. + -h, --help Esta ayuda. + +Parar el bucle a mano: `touch ` y dejar que la TUI salga (o matar su +proceso); en el siguiente ciclo el supervisor ve el sentinel, lo borra y termina. + +Salida: 0 parada voluntaria (sentinel); 1 binario no ejecutable / uso incorrecto; +3 el supervisor se rindio por crash-loop (demasiadas salidas rapidas seguidas). +USAGE + return 0 ;; + --*) + echo "supervise_fleetview_tui: opcion desconocida '$1' (usa -h)" >&2 + return 1 ;; + *) + if [[ -z "$bin" ]]; then + bin="$1" + else + echo "supervise_fleetview_tui: argumento extra '$1' (bin ya es '$bin')" >&2 + return 1 + fi ;; + esac + shift + done + + [[ -z "$bin" ]] && { + echo "supervise_fleetview_tui: falta --bin al binario fleetview. Usa -h." >&2 + return 1 + } + [[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}" + [[ -z "$sentinel" ]] && sentinel="$HOME/.claude/fleet/tui_stop_${socket}" + mkdir -p "$(dirname "$sentinel")" 2>/dev/null || true + + if [[ ! -x "$bin" ]]; then + echo "supervise_fleetview_tui: binario '$bin' no es ejecutable. Compila la TUI: cd apps/fleetview && go build -o fleetview ." >&2 + return 1 + fi + + # Limpiar un sentinel huerfano de una sesion anterior, para no parar al arrancar. + [[ -f "$sentinel" ]] && rm -f "$sentinel" 2>/dev/null || true + + local fast_exits=0 + while true; do + local start end uptime code + start=$(date +%s) + set +e + "$bin" + code=$? + set -e + end=$(date +%s) + uptime=$(( end - start )) + + # Valvula 1 — parada voluntaria por sentinel. + if [[ -f "$sentinel" ]]; then + rm -f "$sentinel" 2>/dev/null || true + echo "[fleetview: parada solicitada via sentinel ($sentinel) — fin del supervisor]" + return 0 + fi + + # Valvula 2 — crash-loop guard. + if [[ "$uptime" -lt "$min_uptime" ]]; then + fast_exits=$(( fast_exits + 1 )) + else + fast_exits=0 + fi + if [[ "$fast_exits" -ge "$max_fast_exits" ]]; then + echo "[fleetview: $fast_exits salidas rapidas seguidas (ultimo code=$code) — el supervisor se rinde para no hacer respawn infinito. Inspecciona el binario y relanza.]" >&2 + return 3 + fi + + echo "[fleetview salio (code=$code, uptime=${uptime}s) — relanzando en ${backoff}s. Para parar: touch $sentinel, o Ctrl-C.]" + sleep "$backoff" + done +} + +# Permitir ejecutar el archivo directamente (no solo como funcion sourced). +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + supervise_fleetview_tui "$@" +fi diff --git a/bash/functions/infra/supervise_fleetview_tui_test.sh b/bash/functions/infra/supervise_fleetview_tui_test.sh new file mode 100644 index 00000000..bd139fee --- /dev/null +++ b/bash/functions/infra/supervise_fleetview_tui_test.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Tests para supervise_fleetview_tui. Usa un binario falso (un script) que cuenta +# sus invocaciones, para verificar respawn, crash-loop guard y sentinel sin correr +# la TUI real. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/supervise_fleetview_tui.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_eq() { + local test_name="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + echo "PASS: $test_name ($actual)" + PASS=$((PASS+1)) + else + echo "FAIL: $test_name — expected '$expected', got '$actual'" + FAIL=$((FAIL+1)) + fi +} + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +COUNTER="$TMP/runs" +SENTINEL="$TMP/sentinel" + +# --- Test 1 (crash-loop guard): binario que sale rapido siempre se rinde a las N --- +# Fake bin: registra una linea por invocacion y sale 1 inmediato. +FAKE_FAST="$TMP/fake_fast.sh" +cat > "$FAKE_FAST" <> "$COUNTER" +exit 1 +EOF +chmod +x "$FAKE_FAST" + +: > "$COUNTER" +set +e +out=$(supervise_fleetview_tui --bin "$FAKE_FAST" --backoff 0 --min-uptime 100 \ + --max-fast-exits 3 --sentinel "$SENTINEL" 2>&1); rc=$? +set -e +runs=$(wc -l < "$COUNTER" | tr -d ' ') +assert_eq "crash-loop: se rinde con rc=3" 3 "$rc" +assert_eq "crash-loop: corrio exactamente 3 veces" 3 "$runs" +assert_contains "crash-loop: mensaje de rendicion" "el supervisor se rinde" "$out" + +# --- Test 2 (golden respawn + sentinel): relanza tras salir, para via sentinel --- +# Fake bin: en la 2a invocacion crea el sentinel, luego sale. Prueba que: +# (a) tras la 1a salida RELANZA (respawn) -> hay 2a invocacion (golden). +# (b) al ver el sentinel, PARA (no hay 3a invocacion). +FAKE_SENT="$TMP/fake_sent.sh" +cat > "$FAKE_SENT" <> "$COUNTER" +n=\$(wc -l < "$COUNTER" | tr -d ' ') +if [[ "\$n" -ge 2 ]]; then + touch "$SENTINEL" +fi +exit 1 +EOF +chmod +x "$FAKE_SENT" + +: > "$COUNTER" +rm -f "$SENTINEL" +set +e +out=$(supervise_fleetview_tui --bin "$FAKE_SENT" --backoff 0 --min-uptime 0 \ + --max-fast-exits 99 --sentinel "$SENTINEL" 2>&1); rc=$? +set -e +runs=$(wc -l < "$COUNTER" | tr -d ' ') +assert_eq "golden: relanzo tras morir (2 invocaciones)" 2 "$runs" +assert_eq "sentinel: para limpio con rc=0" 0 "$rc" +assert_contains "sentinel: mensaje de parada voluntaria" "parada solicitada via sentinel" "$out" +assert_eq "sentinel: el fichero se consume (borrado)" "no" "$([[ -f "$SENTINEL" ]] && echo si || echo no)" +assert_contains "golden: registra el respawn" "relanzando" "$out" + +# --- Test 3 (error): falta --bin --- +set +e +out=$(supervise_fleetview_tui --backoff 0 2>&1); rc=$? +set -e +assert_eq "error: sin --bin devuelve rc=1" 1 "$rc" +assert_contains "error: mensaje falta --bin" "falta --bin" "$out" + +# --- Test 4 (error): binario no ejecutable --- +set +e +out=$(supervise_fleetview_tui --bin "$TMP/no_existe" 2>&1); rc=$? +set -e +assert_eq "error: binario no ejecutable rc=1" 1 "$rc" +assert_contains "error: mensaje no ejecutable" "no es ejecutable" "$out" + +echo "---" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] || exit 1