feat(orchestration): fleet_send_text — nudge fiable por pane_id estable
El nudge del orquestador apuntaba al window_id (@N) de tmux, que migra cuando el focus-swap de FleetView recrea windows (break-pane/join-pane): el texto acababa en el window equivocado o en otro agente (a veces no llega). Ademas, texto y Enter en la misma invocacion hacian que el TUI no interpretara el submit. Nueva funcion fleet_send_text_bash_infra (grupo orchestration) que: - resuelve el pane_id (%N) estable fresco justo antes de enviar (sessionId/PID a pane via tmux list-panes -a + walk de ancestros /proc), no el @N volatil; - manda texto literal y Enter en invocaciones separadas; - verifica con capture-pane que el texto llego antes del submit, con reintento; - guards anti-self y error claro si el target no resuelve a un pane vivo. Test (19/19) sobre socket tmux propio: confirma que tras break-pane el pane_id no migra y el reenvio sigue llegando. orchestration.md (seccion Nudge + catalogo) actualizado para usar la funcion en lugar del send-keys -t <@N> manual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,10 +73,11 @@ el sidecar a mano — filtra por el `role` que ya trae cada fila.
|
||||
`fleet_list` del MCP lo expone como el único identificador y **omite a propósito el `tmux_window`
|
||||
("@N")**, que migra cuando el focus-swap mueve el pane entre windows y por eso nunca debe usarse ni
|
||||
mostrarse como id (la persona no tiene referencia mental de "@4"). Las operaciones internas que sí
|
||||
necesitan la window viva — `focus`, `send-keys`/nudge y `kill` — la resuelven BAJO DEMANDA contra
|
||||
tmux a partir del session_id/PID (`kill_fleet_agent` y `fleetview focus` la recalculan por llamada);
|
||||
para el nudge, lee `tmux_window` del binario `fleetview list --json` (que sí lo conserva como campo
|
||||
interno), nunca del payload del MCP.
|
||||
necesitan la window/pane viva — `focus`, `send-keys`/nudge y `kill` — la resuelven BAJO DEMANDA contra
|
||||
tmux a partir del session_id/PID (`kill_fleet_agent` y `fleetview focus` la recalculan por llamada).
|
||||
Para el **nudge** NO leas ni caches el `@N`: usa `fleet_send_text` (grupo `orchestration`), que resuelve
|
||||
el `pane_id` (`%N`) ESTABLE fresco a partir del `sessionId`/PID en el momento del envío — el `@N` migra
|
||||
con el focus-swap y mandaría el texto al agente equivocado (ver sección Nudge).
|
||||
|
||||
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
|
||||
|
||||
@@ -262,18 +263,24 @@ verificas → `kill_fleet_agent` libera el slot. No uses `pkill`/`killall` ni `k
|
||||
### Nudge — `ESTANCADO`
|
||||
|
||||
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD
|
||||
inyectando en su pane tmux:
|
||||
inyectando texto en su pane con la función `fleet_send_text` (grupo `orchestration`):
|
||||
|
||||
```bash
|
||||
tmux -L "${FLEET_SOCKET:-fleet}" send-keys -t <window_id> \
|
||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." Enter
|
||||
./fn run fleet_send_text <sessionId> \
|
||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." \
|
||||
--socket "$FLEET_SOCKET"
|
||||
```
|
||||
|
||||
El `window_id` es el campo `tmux_window` (p.ej. `@20`) de `apps/fleetview/fleetview list --json`:
|
||||
`fleet_send_text` resuelve el **`pane_id` (`%N`) ESTABLE FRESCO** del agente justo antes de enviar (a
|
||||
partir del `sessionId` → PID → pane, leyendo `tmux list-panes -a` en el momento), y manda el texto
|
||||
literal y el `Enter` en invocaciones **separadas**, verificando con `capture-pane` que el texto llegó
|
||||
antes de hacer submit (reintenta si no). Acepta el target por `sessionId` (exacto o prefijo) o por PID.
|
||||
|
||||
```bash
|
||||
apps/fleetview/fleetview list --json | jq -r '.[] | select(.session_id|startswith("<sid>")) | .tmux_window'
|
||||
```
|
||||
**NO uses `tmux send-keys -t <window_id @N>` a mano para esto.** El `window_id` (`@N`, p.ej. `@20`) que
|
||||
expone `fleetview list --json` MIGRA cuando el focus-swap recrea windows (`break-pane`+`join-pane`):
|
||||
`@32` → `@34`. Enviar al `@N` viejo (cacheado por el bloque `FLEET-STATE` o leído un instante antes)
|
||||
manda el texto al window equivocado o a otro agente — esa era la causa de "el nudge a veces no llega al
|
||||
agente correcto". `fleet_send_text` nunca usa `@N`; usa el `pane_id` (`%N`), que no migra.
|
||||
|
||||
**Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un
|
||||
empujón del bot.
|
||||
@@ -331,6 +338,7 @@ en lote.
|
||||
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
|
||||
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
|
||||
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
|
||||
| `fleet_send_text_bash_infra` | Empujar texto al input de UN agente (nudge) resolviendo su `pane_id` (`%N`) ESTABLE FRESCO justo antes de enviar — NO el `window_id` (`@N`), que migra con el focus-swap y manda el texto al agente equivocado. Texto literal + `Enter` en invocaciones separadas, verificado con `capture-pane` + reintento. Guard anti-self. Reemplaza el `tmux send-keys -t <@N>` manual del nudge |
|
||||
| `notify_desktop_go_infra` | Notificación de escritorio del fleet (`notify-send --app-name=fleetview`, degradación silenciosa si no hay `notify-send`). La usa el orquestador/watcher para avisar a la persona de un `RECLAMA` u otro evento urgente cuando no está mirando la terminal |
|
||||
|
||||
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: fleet_send_text
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
signature: "fleet_send_text <sessionId|PID> \"<texto>\" [--socket <s>] [--no-enter] [--retries N] [--dry-run]"
|
||||
description: "Empuja texto a UN agente de la flota tmux de forma fiable, resolviendo su pane_id (%N) ESTABLE FRESCO justo antes de enviar. Es el reemplazo del nudge antiguo del orquestador, que apuntaba al window_id (@N) leido del JSON de la flota: ese @N MIGRA cuando el focus-swap de FleetView (break-pane + join-pane) recrea windows, asi que enviar al @N viejo (cacheado por el bloque FLEET-STATE o leido un instante antes) mandaba el texto al window equivocado o a otro agente. fleet_send_text resuelve sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un ancestro suyo en /proc) es pane_pid, leyendo tmux list-panes -a en el momento del envio, y usa el pane_id (%N) que NO migra. Ademas manda el texto literal (send-keys -l) y el Enter en invocaciones SEPARADAS, verificando con capture-pane que el texto aparecio en el input antes de pulsar Enter; reintenta si no aparece. Guards: NO envia a tu propio pane; error claro si el target no resuelve a un pane vivo. Por defecto EJECUTA; --dry-run imprime el plan sin enviar."
|
||||
tags: [fleet, claude-fleet, orchestration, tmux, nudge, send-keys, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
error_type: error_go_core
|
||||
file_path: "bash/functions/infra/fleet_send_text.sh"
|
||||
tested: true
|
||||
tests:
|
||||
- "golden: envio por PID resuelve el pane_id estable, inyecta el texto y se verifica via capture-pane"
|
||||
- "edge: tras break-pane (focus-swap) el pane_id NO migra y el reenvio sigue llegando"
|
||||
- "edge: resolucion por prefijo de sessionId (sessions/<pid>.json) entrega el texto"
|
||||
- "edge: --dry-run no inyecta nada y reporta status=dry-run"
|
||||
- "error: sessionId no resuelto rc=2; falta texto rc=2; PID sin pane vivo rc=4"
|
||||
- "guard: enviar a la sesion actual (self) rc=3"
|
||||
test_file_path: "bash/functions/infra/fleet_send_text_test.sh"
|
||||
params:
|
||||
- name: target
|
||||
desc: "Primer arg posicional: sessionId del agente (exacto o prefijo) o su PID (todo digitos). Por sessionId se busca en sessions/*.json el que case y su archivo (<pid>.json) da el PID; por PID se usa directo."
|
||||
- name: texto
|
||||
desc: "Segundo arg posicional: el texto a inyectar en el input del agente (entre comillas)."
|
||||
- name: --socket
|
||||
desc: "Socket tmux del perfil FleetView donde vive el pane. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
|
||||
- name: --no-enter
|
||||
desc: "Deja el texto en el input sin pulsar Enter (no hace submit). Por defecto envia el Enter en una invocacion separada tras el texto."
|
||||
- name: --retries
|
||||
desc: "Numero de reintentos si el texto no aparece en el pane tras el send (default 2). Cada reintento limpia el input con C-u antes de reenviar."
|
||||
- name: --dry-run
|
||||
desc: "Imprime el plan (PID, sessionId, pane, socket) y NO envia nada. Sin esto, ejecuta."
|
||||
output: "Imprime una linea de plan (target, PID, sessionId, socket, pane resuelto, modo de envio) y una linea final parseable 'pane=%N intento=N status=ok|dry-run'. Exit 0 ok/dry-run; 2 uso incorrecto o target no resuelto a PID; 3 guard (target es la sesion actual); 4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los reintentos."
|
||||
---
|
||||
|
||||
# fleet_send_text
|
||||
|
||||
Empuja texto al input de **un** agente de la flota tmux de forma fiable. Resuelve el `pane_id` (`%N`) **estable** del agente **fresco** justo antes de enviar (nunca cachea el `window_id` `@N`, que migra con el focus-swap), manda el texto literal y el `Enter` en invocaciones **separadas**, y verifica con `capture-pane` que el texto llegó antes de hacer submit. Es el reemplazo del patrón de nudge antiguo (`tmux send-keys -t <window_id @N>`), que fallaba "a veces" porque enviaba al window equivocado tras un focus-swap.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Nudge a un ejecutor estancado por sessionId (el orquestador lo llama tras detectar ESTANCADO):
|
||||
./fn run fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 \
|
||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: el error path con evidencia. Cierralo o reporta el bloqueo." \
|
||||
--socket "$FLEET_SOCKET"
|
||||
|
||||
# Por prefijo de sessionId, en el socket por defecto ($FLEET_SOCKET o "fleet"):
|
||||
./fn run fleet_send_text 32945650 "Recuerda pushear la rama antes de cerrar."
|
||||
|
||||
# Dejar texto en el input sin hacer submit (--no-enter), o solo ver el plan (--dry-run):
|
||||
./fn run fleet_send_text 48213 "borrador..." --no-enter
|
||||
./fn run fleet_send_text 48213 "texto" --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala desde el modo orquestador siempre que necesites **inyectar texto en el input de un agente** de la flota: el **nudge** a un `ESTANCADO`, el aviso de un gap concreto a un ejecutor cuyo cierre falló la verificación, o cualquier mensaje dirigido. Sustituye al `tmux send-keys -t <window_id>` manual. Resuelve el target por sessionId (exacto o prefijo) o por PID. **Solo a idle/ESTANCADO; jamás a un agente en `waiting`/`preguntando`** (esos te reclaman a ti, no un empujón del bot). Para *cerrar* un ejecutor verificado `met` no es esto: usa `kill_fleet_agent`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El bug que arregla — el `window_id` (`@N`) MIGRA**: el focus-swap de FleetView (`tmux_swap_window_into_console.go`) trae el claude objetivo a la console con `break-pane` + `join-pane`, lo que **recrea windows** y cambia el `@N` del agente (`@32` → `@34`). El bloque `FLEET-STATE` y el JSON de la flota pueden traer un `@N` ya viejo. Enviar a ese `@N` manda el texto al window equivocado o a otro agente. Esta función NUNCA usa `@N`: resuelve el `pane_id` (`%N`), que se **preserva** durante toda la vida del pane aunque el pane se mueva de window. Verificado en test: tras `break-pane` el `window_id` pasa de `@0` a `@1` pero el `pane_id` sigue `%0` y el envío sigue llegando.
|
||||
- **Resolución fresca**: el mapa `pane_pid → pane_id` se lee con `tmux -L <socket> list-panes -a` **en el momento del envío**, no se cachea. La resolución sube por los ancestros de `/proc` desde el PID del agente hasta casar un `pane_pid`: cubre tanto `exec claude` (pane_pid == claude pid, match directo, como hace `spawn_fleet_agent`) como un claude lanzado bajo un shell (pane_pid == shell ancestro).
|
||||
- **Texto y Enter separados**: el texto va con `send-keys -l` (literal, sin interpretar nombres de tecla), luego `sleep 0.3`, y el `Enter` en una **invocación aparte**. Mandar texto+Enter juntos hace que el TUI de Claude Code a veces no interprete el Enter como submit. La verificación con `capture-pane` se hace **antes** del Enter (tras el submit el TUI vacía el input y no se podría comprobar). Si el texto no aparece, limpia el input con `C-u` y reintenta (`--retries`, default 2).
|
||||
- **Impura**: inyecta teclas en un pane ajeno. Por defecto EJECUTA; usa `--dry-run` para inspeccionar el plan antes.
|
||||
- **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 autoenvio").
|
||||
- **Verificación por fragmento ancla**: comprueba que aparezcan los primeros 24 caracteres del texto (no el texto completo) para no dar falso negativo cuando el input del TUI wrapea un mensaje largo en varias líneas.
|
||||
- **Socket**: si no pasas `--socket`, usa `$FLEET_SOCKET` o `"fleet"`. Si el agente no está en ese socket, no se encontrará el pane (exit 4).
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
# fleet_send_text — empuja texto a UN agente de la flota tmux de forma fiable.
|
||||
#
|
||||
# El problema que resuelve: el orquestador "nudgea" a los ejecutores con
|
||||
# `tmux send-keys`. El patron antiguo apuntaba al `window_id` (`@N`) leido del
|
||||
# JSON de la flota. Pero el focus-swap de FleetView (`break-pane` + `join-pane`)
|
||||
# RECREA windows, asi que el `@N` de un agente MIGRA (p.ej. `@32` -> `@34`) cada
|
||||
# vez que se entra/sale de su window. Enviar al `@N` viejo (cacheado por el bloque
|
||||
# FLEET-STATE o leido un instante antes) manda el texto al window equivocado o a
|
||||
# otro agente -> "a veces no llega al agente correcto". Ademas, mandar el texto y
|
||||
# el `Enter` en la MISMA invocacion hace que el TUI de Claude Code a veces no
|
||||
# interprete el Enter como submit.
|
||||
#
|
||||
# Esta funcion arregla las dos cosas:
|
||||
# 1. Resuelve el `pane_id` ESTABLE (`%N`) FRESCO justo antes de enviar. El
|
||||
# `pane_id` se preserva durante toda la vida del pane aunque el pane se mueva
|
||||
# de window con break/join — NO migra como el `window_id`. La resolucion va
|
||||
# sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un
|
||||
# ancestro suyo en /proc) es `pane_pid`, leyendo `tmux list-panes -a` en el
|
||||
# momento del envio.
|
||||
# 2. Manda el texto literal (`send-keys -l`), espera un poco, y el `Enter` en
|
||||
# una invocacion SEPARADA. Verifica con `capture-pane` que el texto aparecio
|
||||
# en el pane antes de pulsar Enter; si no, reintenta.
|
||||
#
|
||||
# Guards: NO envia a tu propio pane (la sesion que invoca la funcion). Error claro
|
||||
# si el sessionId/PID no resuelve a un pane vivo.
|
||||
#
|
||||
# Funcion IMPURA: inyecta teclas en un pane tmux ajeno. Por defecto EJECUTA (es el
|
||||
# caso de uso del bot: nudgear a un ejecutor). Usa --dry-run para ver el plan sin
|
||||
# enviar nada.
|
||||
#
|
||||
# Overrides de entorno (testabilidad, no para uso normal):
|
||||
# FN_FLEET_SESSIONS_DIR directorio de los sessions JSON. Default ~/.claude/sessions
|
||||
# FN_FLEET_SELF_PID fuerza el PID propio (salta la deteccion por /proc)
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
# Resuelve el pane_id (%N) ESTABLE de un PID dado, leyendo el mapa fresco de panes
|
||||
# del socket. Sube por la cadena de ancestros del PID en /proc hasta encontrar un
|
||||
# `pane_pid` del mapa: cubre tanto el caso `exec claude` (pane_pid == claude pid,
|
||||
# match directo) como el de un claude lanzado bajo un shell (pane_pid == shell
|
||||
# ancestro). Imprime el pane_id y devuelve 0 si lo encuentra; 1 si no.
|
||||
# $1 = PID objetivo
|
||||
# $2 = texto del mapa "pane_pid pane_id" (una linea por pane)
|
||||
_fleet_resolve_pane_for_pid() {
|
||||
local p="${1:-}" panes_map="${2:-}" guard=0 pane_id
|
||||
while [[ -n "$p" && "$p" != "0" && "$p" != "1" ]]; do
|
||||
pane_id="$(awk -v pp="$p" '$1==pp {print $2; exit}' <<<"$panes_map")"
|
||||
if [[ -n "$pane_id" ]]; then
|
||||
printf '%s\n' "$pane_id"
|
||||
return 0
|
||||
fi
|
||||
p="$(awk '{print $4}' "/proc/$p/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
fleet_send_text() {
|
||||
local target="" txt="" socket="" do_enter=1 dry=0 retries=2
|
||||
local got_target=0 got_text=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--socket) shift; socket="${1:-}" ;;
|
||||
--no-enter) do_enter=0 ;;
|
||||
--retries) shift; retries="${1:-2}" ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: fleet_send_text <sessionId|PID> "<texto>" [--socket <s>] [--no-enter] [--retries N] [--dry-run]
|
||||
|
||||
Empuja <texto> a UN agente de la flota tmux resolviendo su pane_id (%N) ESTABLE
|
||||
FRESCO justo antes de enviar (no cachea el window_id @N, que migra con el
|
||||
focus-swap). Manda el texto literal y el Enter en invocaciones separadas, y
|
||||
verifica con capture-pane que el texto aparecio antes de pulsar Enter;
|
||||
reintenta si no.
|
||||
|
||||
Argumentos:
|
||||
<sessionId|PID> Primer posicional: sessionId del agente (exacto o prefijo) o
|
||||
su PID (todo digitos). Por sessionId se busca en
|
||||
sessions/*.json el que case; su archivo (<pid>.json) da el PID.
|
||||
"<texto>" Segundo posicional: el texto a inyectar en el input del agente.
|
||||
|
||||
Opciones:
|
||||
--socket <s> Socket tmux del perfil FleetView. Default: $FLEET_SOCKET, o "fleet".
|
||||
--no-enter Deja el texto en el input sin pulsar Enter (no hace submit).
|
||||
--retries N Reintentos si el texto no aparece tras el send (default 2).
|
||||
--dry-run Imprime el plan (PID, sessionId, pane, socket) y NO envia nada.
|
||||
-h, --help Esta ayuda.
|
||||
|
||||
Salida: linea de resultado con `pane=%N` usado e `intento=N`. Exit 0 ok/dry-run;
|
||||
2 uso incorrecto o target no resuelto; 3 guard (target es la sesion actual);
|
||||
4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los
|
||||
reintentos.
|
||||
|
||||
Ejemplos:
|
||||
fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 "Cierra tu DoD o reporta el bloqueo." --socket "$FLEET_SOCKET"
|
||||
fleet_send_text 32945650 "Falta el error path con evidencia." # por prefijo de sessionId
|
||||
fleet_send_text 48213 "texto" --no-enter --dry-run # por PID, solo ver el plan
|
||||
USAGE
|
||||
return 0 ;;
|
||||
--*)
|
||||
echo "fleet_send_text: opcion desconocida '$1' (usa -h)" >&2
|
||||
return 2 ;;
|
||||
*)
|
||||
if [[ "$got_target" -eq 0 ]]; then
|
||||
target="$1"; got_target=1
|
||||
elif [[ "$got_text" -eq 0 ]]; then
|
||||
txt="$1"; got_text=1
|
||||
else
|
||||
echo "fleet_send_text: argumento extra '$1' (target y texto ya fijados)" >&2
|
||||
return 2
|
||||
fi ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$got_target" -eq 0 ]] && {
|
||||
echo "fleet_send_text: falta el target (sessionId o PID). Usa -h." >&2
|
||||
return 2
|
||||
}
|
||||
[[ "$got_text" -eq 0 ]] && {
|
||||
echo "fleet_send_text: falta el texto a enviar. Usa -h." >&2
|
||||
return 2
|
||||
}
|
||||
[[ "$retries" =~ ^[0-9]+$ ]] || {
|
||||
echo "fleet_send_text: --retries debe ser un entero (recibido '$retries')" >&2
|
||||
return 2
|
||||
}
|
||||
|
||||
local sessions_dir="${FN_FLEET_SESSIONS_DIR:-$HOME/.claude/sessions}"
|
||||
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || {
|
||||
echo "fleet_send_text: tmux no esta instalado" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Resolver (PID, sessionId) a partir del target. Mismo patron que
|
||||
# kill_fleet_agent: por PID directo, o por sessionId (exacto/prefijo)
|
||||
# buscando en sessions/*.json.
|
||||
# -----------------------------------------------------------------------
|
||||
local pid="" sid=""
|
||||
if [[ "$target" =~ ^[0-9]+$ ]]; then
|
||||
pid="$target"
|
||||
local sfile="$sessions_dir/$pid.json"
|
||||
if [[ -f "$sfile" ]] && command -v jq >/dev/null 2>&1; then
|
||||
sid="$(jq -r '.sessionId // ""' "$sfile" 2>/dev/null || true)"
|
||||
fi
|
||||
else
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "fleet_send_text: jq no esta instalado (necesario para resolver el sessionId)" >&2
|
||||
return 1
|
||||
}
|
||||
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 "fleet_send_text: no se pudo resolver el target '$target' a un PID (sessions en $sessions_dir)" >&2
|
||||
return 2
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Guard — anti-self: no enviar 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 "fleet_send_text: REHUSADO — el target (PID $pid) es la sesion actual. No me autoenvio." >&2
|
||||
return 3
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Resolver el pane_id (%N) ESTABLE FRESCO. Mapa pane_pid->pane_id del socket
|
||||
# leido AHORA; subir por ancestros del PID hasta casar un pane_pid.
|
||||
# -----------------------------------------------------------------------
|
||||
local panes_map pane=""
|
||||
panes_map="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{pane_id}' 2>/dev/null || true)"
|
||||
if [[ -n "$panes_map" ]]; then
|
||||
pane="$(_fleet_resolve_pane_for_pid "$pid" "$panes_map" || true)"
|
||||
fi
|
||||
|
||||
[[ -z "$pane" ]] && {
|
||||
echo "fleet_send_text: no se encontro un pane vivo para el target '$target' (PID $pid) en el socket '$socket'." >&2
|
||||
return 4
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Plan.
|
||||
# -----------------------------------------------------------------------
|
||||
local enter_desc; [[ "$do_enter" -eq 1 ]] && enter_desc="texto + Enter separado" || enter_desc="solo texto (--no-enter)"
|
||||
echo "fleet_send_text — target: $target PID: $pid sessionId: ${sid:-?} socket: $socket pane: $pane envio: $enter_desc retries: $retries"
|
||||
|
||||
if [[ "$dry" -eq 1 ]]; then
|
||||
echo "DRY-RUN: no se ha enviado nada."
|
||||
echo "pane=$pane intento=0 status=dry-run"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Enviar + verificar. El texto se manda literal (-l); el Enter va en una
|
||||
# invocacion separada tras un sleep. Verificamos ANTES del Enter (el texto
|
||||
# esta en el input; tras Enter el TUI vacia el input y no se podria verificar).
|
||||
# Si el texto no aparece, limpiamos el input (C-u) y reintentamos.
|
||||
# -----------------------------------------------------------------------
|
||||
local anchor="${txt:0:24}" # fragmento ancla (evita falsos negativos por wrapping)
|
||||
local i cap ok=0 used_try=0
|
||||
for (( i=1; i<=retries+1; i++ )); do
|
||||
tmux -L "$socket" send-keys -t "$pane" -l -- "$txt" 2>/dev/null || true
|
||||
sleep 0.3
|
||||
cap="$(tmux -L "$socket" capture-pane -p -t "$pane" 2>/dev/null || true)"
|
||||
if grep -qF -- "$anchor" <<<"$cap"; then
|
||||
ok=1; used_try="$i"
|
||||
break
|
||||
fi
|
||||
# No aparecio: limpiar el input antes de reintentar.
|
||||
tmux -L "$socket" send-keys -t "$pane" C-u 2>/dev/null || true
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [[ "$ok" -ne 1 ]]; then
|
||||
echo "fleet_send_text: texto enviado pero NO verificado en el pane $pane tras $((retries+1)) intentos." >&2
|
||||
echo "pane=$pane intento=$((retries+1)) status=unverified" >&2
|
||||
return 5
|
||||
fi
|
||||
|
||||
# Texto presente en el input. Ahora el Enter (separado) para hacer submit.
|
||||
if [[ "$do_enter" -eq 1 ]]; then
|
||||
tmux -L "$socket" send-keys -t "$pane" Enter 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "fleet_send_text: OK — texto inyectado en el pane $pane (intento $used_try)$([[ "$do_enter" -eq 1 ]] && echo " + Enter")."
|
||||
echo "pane=$pane intento=$used_try status=ok"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
fleet_send_text "$@"
|
||||
fi
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para fleet_send_text. Levanta un socket tmux PROPIO de test
|
||||
# (fleet_test_<pid>, nunca el socket "fleet" real) con un pane `cat` vivo, y
|
||||
# verifica: envio + verificacion via capture-pane (golden), supervivencia al
|
||||
# focus-swap (break-pane preserva el pane_id), resolucion por sessionId fake,
|
||||
# y los paths de error/guard. No toca la flota real ni ningun agente.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/fleet_send_text.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_not_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "FAIL: $test_name — should NOT contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
else
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS+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
|
||||
}
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "SKIP: tmux no instalado"; exit 0; }
|
||||
|
||||
# --- Socket de test PROPIO + pane `cat` vivo (con echo de tty) ---
|
||||
SOCK="fleet_test_$$"
|
||||
TMP="$(mktemp -d)"
|
||||
SESS="$TMP/sessions"
|
||||
mkdir -p "$SESS"
|
||||
|
||||
cleanup() {
|
||||
tmux -L "$SOCK" kill-server 2>/dev/null || true
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tmux -L "$SOCK" new-session -d -s t -x 120 -y 30 'cat'
|
||||
sleep 0.4
|
||||
|
||||
PANE_PID="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid}' | head -n1)"
|
||||
PANE_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
WIN_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
echo "INFO: socket=$SOCK pane_pid=$PANE_PID pane_id=$PANE_ID0 window_id=$WIN_ID0"
|
||||
|
||||
# self_pid forzado a un PID que nunca sera target en los tests golden.
|
||||
export FN_FLEET_SELF_PID=1
|
||||
export FN_FLEET_SESSIONS_DIR="$SESS"
|
||||
|
||||
# --- Test 1 (golden): enviar por PID, verificar via capture-pane ---
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" "HOLA_FLEET_123" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "golden: envio por PID sale 0" 0 "$rc"
|
||||
assert_contains "golden: reporta status=ok" "status=ok" "$out"
|
||||
assert_contains "golden: reporta el pane_id estable" "pane=$PANE_ID0" "$out"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID0")"
|
||||
assert_contains "golden: el texto llego al pane (capture-pane)" "HOLA_FLEET_123" "$cap"
|
||||
|
||||
# limpiar input del cat
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-u; sleep 0.2
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-l 2>/dev/null || true; sleep 0.2
|
||||
|
||||
# --- Test 2 (edge focus-swap): mover el pane a otra window, pane_id NO migra ---
|
||||
# Anadimos un segundo pane para poder break-pane el nuestro a una window nueva.
|
||||
tmux -L "$SOCK" split-window -t "$WIN_ID0" -d 'cat'; sleep 0.3
|
||||
tmux -L "$SOCK" break-pane -d -s "$PANE_ID0"; sleep 0.3
|
||||
WIN_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
PANE_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
echo "INFO: tras break-pane: pane_id=$PANE_ID1 (era $PANE_ID0) window_id=$WIN_ID1 (era $WIN_ID0)"
|
||||
assert_contains "edge: pane_id NO cambia tras mover de window" "$PANE_ID0" "$PANE_ID1"
|
||||
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" "TRAS_MOVER_456" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "edge: reenvio tras focus-swap sale 0" 0 "$rc"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||
assert_contains "edge: el texto sigue llegando tras mover de window" "TRAS_MOVER_456" "$cap"
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
|
||||
|
||||
# --- Test 3 (edge): resolver por sessionId (sessions/<pid>.json fake) ---
|
||||
echo "{\"sessionId\":\"test-sid-aaa-111\",\"cwd\":\"/tmp/x\"}" > "$SESS/$PANE_PID.json"
|
||||
set +e
|
||||
out=$(fleet_send_text "test-sid-aaa" "VIA_SID_789" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "edge: resolucion por prefijo de sessionId sale 0" 0 "$rc"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||
assert_contains "edge: texto llego resolviendo por sessionId" "VIA_SID_789" "$cap"
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
|
||||
|
||||
# --- Test 4 (edge): --dry-run no envia nada ---
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" "NO_DEBE_APARECER_000" --socket "$SOCK" --no-enter --dry-run 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "edge: dry-run sale 0" 0 "$rc"
|
||||
assert_contains "edge: dry-run reporta status=dry-run" "status=dry-run" "$out"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||
assert_not_contains "edge: dry-run NO inyecto texto" "NO_DEBE_APARECER_000" "$cap"
|
||||
|
||||
# --- Test 5 (error): sessionId que no resuelve a PID -> rc 2 ---
|
||||
set +e
|
||||
out=$(fleet_send_text "sid-inexistente-zzz" "x" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "error: sessionId no resuelto sale 2" 2 "$rc"
|
||||
assert_contains "error: mensaje de target no resuelto" "no se pudo resolver" "$out"
|
||||
|
||||
# --- Test 6 (error): falta el texto -> rc 2 ---
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "error: falta texto sale 2" 2 "$rc"
|
||||
|
||||
# --- Test 7 (guard anti-self): target == self_pid -> rc 3 ---
|
||||
set +e
|
||||
out=$(FN_FLEET_SELF_PID="$PANE_PID" fleet_send_text "$PANE_PID" "x" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "guard: enviar a la sesion actual sale 3" 3 "$rc"
|
||||
assert_contains "guard: mensaje anti-self" "No me autoenvio" "$out"
|
||||
|
||||
# --- Test 8 (error): PID sin pane vivo -> rc 4 ---
|
||||
set +e
|
||||
out=$(fleet_send_text 999999 "x" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "error: PID sin pane vivo sale 4" 4 "$rc"
|
||||
assert_contains "error: mensaje no pane vivo" "no se encontro un pane vivo" "$out"
|
||||
|
||||
# --- Resumen ---
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "PASS: $PASS FAIL: $FAIL"
|
||||
echo "================================"
|
||||
[[ "$FAIL" -eq 0 ]]
|
||||
Reference in New Issue
Block a user