# Cómo verificar un estado remoto desconocido tras una desconexión SSH

Una desconexión de SSH no significa que el comando remoto haya fallado. Significa que el cliente perdió la evidencia necesaria para clasificar lo ocurrido. La diferencia resulta molesta cuando ejecutabas `uname`, y puede salir cara cuando ejecutabas una migración de base de datos, un lanzamiento, una rotación de credenciales o un comando que llama a una API externa.

La solución no es aumentar el tiempo de espera. La solución es una secuencia de verificación remota con una identidad de ejecución persistente, transiciones de estado explícitas y una prueba de finalización específica para el efecto. Cuando existe esa secuencia, reconectarse cambia la pregunta de «¿Debería ejecutarlo otra vez?» a «¿Qué dice el registro de esta ejecución y qué ha cambiado?»

## Una conexión interrumpida deja tres respuestas honestas

Después de que un cliente SSH informe de un restablecimiento, un tiempo de espera, una tubería rota o un EOF inesperado, el comando puede estar en uno de tres estados: nunca comenzó, comenzó y sigue en curso, o terminó. El código de salida del cliente no distingue estos casos de forma fiable.

Hay varios límites entre tu shell y el programa remoto:

- Tu shell local inicia `ssh`.
- El cliente envía una solicitud de canal SSH y los bytes del comando.
- El servidor acepta la solicitud e inicia un shell o programa remoto.
- Ese programa realiza el trabajo real.
- El programa termina y sshd devuelve la salida y el estado de salida.

Una interrupción de red después de cualquiera de esos límites puede producir un error local. Si ocurre antes de que se inicie el programa remoto, no ha pasado nada. Si ocurre después de que el programa confirme un cambio, pero antes de que vuelva el estado de salida, el cambio se ha producido aunque el cliente siga informando de un fallo.

Por eso un mensaje local como `Connection reset by peer` es evidencia del transporte, no evidencia del negocio. Indica que el cliente no pudo terminar la conversación SSH. No indica si la operación remota se ejecutó.

El propio manual de configuración de OpenSSH señala algo relacionado sobre los canales inactivos: cerrar una sesión no garantiza que los procesos de shell asociados se hayan detenido. Por tanto, el tiempo de espera de un canal no es un mecanismo de control de trabajos.

El hábito que causa problemas consiste en tratar un resultado ambiguo como una operación fallida. Es comprensible, porque la mayoría de las herramientas de línea de comandos nos enseñan a interpretar un estado de salida distinto de cero como «hay que repetirlo». Un fallo del transporte SSH rompe ese atajo.

## La entrega del comando y su finalización son afirmaciones distintas

Un comando remoto tiene al menos cuatro afirmaciones que conviene demostrar: envío, inicio, finalización y efecto. Los equipos suelen registrar una de ellas y asumir que tienen las cuatro.

El envío significa que el cliente intentó mandar el comando. Tu terminal local lo sabe, pero es la afirmación más débil. El inicio significa que el wrapper remoto creó evidencia persistente antes de trabajar. La finalización significa que el wrapper registró un resultado final. El efecto significa que el estado remoto o externo deseado coincide ahora con el resultado solicitado.

Una lista de procesos demuestra menos de lo que parece. Ver un PID puede indicar que existe ahora un proceso con un nombre parecido. No demuestra que pertenezca a tu solicitud, que no haya confirmado ya la parte importante ni que un reintento posterior sea seguro. Cuando un proceso termina, la reutilización del PID hace que los registros antiguos sean todavía menos útiles.

Los códigos de salida tienen un límite parecido. POSIX define `wait` como una forma de que un shell obtenga el estado de un proceso hijo que conoce. Esa relación existe dentro del shell remoto. Cuando desaparece la conexión SSH, el shell local pierde el camino hasta ese estado. Una sesión SSH posterior no puede reconstruirlo mediante `wait`; tiene que leer un registro que la primera ejecución haya guardado.

Mantén separadas estas afirmaciones en los runbooks y en la salida de la automatización:

1. «El cliente no pudo confirmar la finalización».
2. «La ejecución `r-20260722-1842-a91f` comenzó en el host remoto».
3. «Esa ejecución registró el estado de salida 0».
4. «El marcador de despliegue informa de la versión `2026.07.22.3`».

La cuarta afirmación puede ser la única que responda a la pregunta operativa real. Un comando que copia archivos necesita una suma de comprobación o una ruta final con el contenido esperado. Una migración necesita su versión de esquema o una entrada en el registro de migraciones. Una solicitud a una API de pagos o de tickets necesita un registro de idempotencia en esa API, no solo una línea en un registro local.

## Coloca la identidad de ejecución en el host remoto antes de trabajar

Un ID de ejecución persistente convierte una reconexión imprecisa en una consulta concreta. Genéralo antes de invocar SSH, pásalo al wrapper remoto y haz que cada artefacto viva en una ruta derivada de él.

No uses solo una marca de tiempo. Dos agentes pueden comenzar en el mismo segundo, los relojes pueden desviarse y las marcas de tiempo no son buenos identificadores opacos. Combina una marca de tiempo con datos aleatorios o usa un generador de UUID disponible en tu entorno. El ID debe volver a proporcionarse durante la verificación y aparecer en cada entrada de registro relevante.

Este fragmento de shell crea un directorio de ejecución, escribe la operación solicitada, registra un marcador de inicio y conserva ambos flujos de salida. Espera que se pase un comando después de `--`. Mantén el wrapper en una ubicación controlada, como `/usr/local/sbin/run-recorded`, en lugar de copiarlo de forma improvisada en cada cadena de comandos.

```sh
#!/bin/sh
set -eu

run_id=$1
shift
[ "$1" = "--" ]
shift

base=/var/lib/recorded-runs
run_dir="$base/$run_id"

case "$run_id" in
  *[!A-Za-z0-9._-]*|'')
    printf '%s\n' "invalid run id" >&2
    exit 64
    ;;
esac

if ! mkdir "$run_dir" 2>/dev/null; then
  printf '%s\n' "run already exists: $run_id" >&2
  exit 75
fi

umask 077
printf '%s\n' "$*" > "$run_dir/request"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/started_at"
printf '%s\n' "started" > "$run_dir/state"
printf '%s\n' "$$" > "$run_dir/pid"

set +e
"$@" >"$run_dir/stdout" 2>"$run_dir/stderr"
status=$?
set -e

printf '%s\n' "$status" > "$run_dir/exit_status"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/finished_at"
printf '%s\n' "finished" > "$run_dir/state"
exit "$status"
```

La llamada a `mkdir` hace algo más que ocuparse de la organización. La creación del directorio falla si ese ID ya existe, así que actúa como una reclamación sencilla de creación única. Esto evita que dos invocaciones con el mismo ID hagan el trabajo dos veces sin avisar. No resuelve el trabajo simultáneo que usa IDs distintos, para lo que hace falta un bloqueo separado o una restricción a nivel de aplicación.

El orden importa. El wrapper escribe `started_at`, `state` y `pid` antes de ejecutar la carga útil. Registra `exit_status` antes de cambiar el estado a `finished`. Un verificador que vea `finished` sin un estado de salida debe tratar el registro como dañado, no como correcto. Si ve un directorio de ejecución pero no encuentra `started_at`, debe tratarlo como un fallo de configuración incompleto.

No escribas `finished` mediante una trampa del shell y des por terminado el trabajo. Un fallo repentino del host, un fallo de almacenamiento, una terminación forzada o un problema del sistema de archivos pueden impedir que se ejecuten las trampas. Un marcador final es una evidencia cuando está presente, pero su ausencia no demuestra que la carga útil no haya terminado.

## Verifica una ejecución en un orden que no te engañe

Vuelve a conectarte primero con un comando de estado de solo lectura. No vuelvas a conectarte iniciando otra vez la carga útil con los mismos argumentos y esperando que la respuesta resulte evidente.

Un verificador útil debe clasificar el registro como `absent`, `running`, `finished` o `damaged`. Este ejemplo usa el formato de directorio anterior e imprime datos que una persona o un agente pueden evaluar.

```sh
#!/bin/sh
set -eu

run_id=$1
run_dir="/var/lib/recorded-runs/$run_id"

if [ ! -d "$run_dir" ]; then
  printf '%s\n' 'state=absent'
  exit 0
fi

if [ ! -f "$run_dir/started_at" ]; then
  printf '%s\n' 'state=damaged reason=missing-start-marker'
  exit 2
fi

if [ -f "$run_dir/finished_at" ] && [ -f "$run_dir/exit_status" ]; then
  printf '%s\n' 'state=finished'
  printf 'exit_status=%s\n' "$(cat "$run_dir/exit_status")"
  printf 'started_at=%s\n' "$(cat "$run_dir/started_at")"
  printf 'finished_at=%s\n' "$(cat "$run_dir/finished_at")"
  exit 0
fi

if [ -f "$run_dir/pid" ]; then
  pid=$(cat "$run_dir/pid")
  if kill -0 "$pid" 2>/dev/null; then
    printf 'state=running pid=%s\n' "$pid"
    exit 0
  fi
fi

printf '%s\n' 'state=damaged reason=no-finish-record-and-pid-not-live'
exit 2
```

Ejecútalo como un comando SSH nuevo:

```sh
ssh ops@host /usr/local/sbin/check-recorded-run r-20260722-1842-a91f
```

Su salida debería coincidir con uno de estos casos:

```text
state=absent
```

```text
state=running pid=48192
```

```text
state=finished
exit_status=0
started_at=2026-07-22T18:42:19Z
finished_at=2026-07-22T18:47:03Z
```

El estado incómodo `damaged` debe formar parte del protocolo. Si lo omites, obligas al verificador a convertir la falta de evidencia en una suposición optimista. Si el host se reinició mientras se ejecutaba el comando, `kill -0` fallará y no habrá ningún marcador final. La respuesta correcta es inspeccionar el efecto esperado y los registros de la aplicación, y decidir después si hace falta una acción de conciliación.

No hagas que el verificador use `ps | grep`. Coincidirá con procesos no relacionados, los nombres de los comandos cambian y los formatos de salida varían. `kill -0` solo ofrece una pista de actividad para un PID registrado. No demuestra que el trabajo haya terminado, por eso el verificador comprueba primero los artefactos finales y consulta después el PID.

## Un estado de salida finalizado aún puede no demostrar el efecto deseado

El registro final del wrapper demuestra lo que observó el wrapper, no necesariamente lo que aceptó el mundo exterior. Esto resulta evidente con los comandos que envían solicitudes.

Imagina un script remoto que crea un registro DNS mediante una API y después escribe `exit_status=0`. El script puede recibir una respuesta correcta de la API antes de que un resolvedor vea el nuevo registro. Un script de despliegue puede terminar correctamente después de enviar un lanzamiento que más tarde falla en las comprobaciones de salud. Una herramienta de base de datos puede informar de una conexión correcta mientras una sentencia de un procedimiento de varios pasos confirma cambios y otra posterior falla.

Cada operación necesita una poscondición que encaje con su efecto. La poscondición debe poder leerse repetidamente de forma segura y ser lo bastante específica para rechazar un resultado antiguo o no relacionado.

Para un lanzamiento, escribe el ID de ejecución en un manifiesto de lanzamiento y consulta la versión activa del servicio. Para un cambio de base de datos, consulta la tabla de migraciones tanto para el identificador de migración como para su suma de comprobación. Para un artefacto generado, compara un resumen SHA-256 calculado previamente después de colocar el archivo en su ruta final. Para una solicitud de API, proporciona el token de idempotencia del proveedor cuando exista y consulta después el recurso resultante mediante ese token o mediante un ID de solicitud que hayas guardado.

El peor diseño es un script que emite «done» después de enviar una solicitud y trata esa palabra como evidencia. El archivo stdout te dice lo que imprimió un proceso. Una poscondición te dice qué contiene ahora el sistema.

Esta diferencia también indica cuándo una operación no puede automatizarse de forma segura usando solo SSH. Si el comando remoto llama a un servicio externo que no ofrece controles de idempotencia y no permite buscar una solicitud anterior, puede ser imposible clasificar una llamada interrumpida. Añade una aprobación humana o un proceso compensatorio. Más reintentos no crearán la evidencia que falta.

## La idempotencia es mejor que una falsa sensación de recuperación

Un protocolo de verificación reduce la incertidumbre. Un diseño de comandos idempotente reduce el coste de esa incertidumbre. Necesitas ambas cosas.

Una operación idempotente alcanza el mismo estado deseado cuando se aplica otra vez con la misma solicitud. `mkdir -p /srv/app/cache` se acerca a ese modelo. `useradd deploy` no, a menos que el script compruebe primero que la cuenta existente tiene las propiedades esperadas. `curl -X POST /orders` no es idempotente salvo que el servicio entienda un token de idempotencia y trate un token repetido como la misma solicitud.

No confundas «probablemente el comando no hará nada en la segunda ejecución» con idempotencia. Un comando de despliegue puede sobrescribir un archivo de la misma forma dos veces, pero activar un reinicio en ambas ocasiones. Una herramienta de migración puede reconocer su propio historial y aun así ejecutar una inicialización peligrosa antes de comprobarlo. Lee el comportamiento del comando y prueba el caso de interrupción.

Construye las solicitudes alrededor de un identificador de operación estable. Pasa el mismo ID al wrapper remoto y, siempre que sea posible, al sistema de destino. Un wrapper de lanzamiento remoto podría crear `/var/lib/recorded-runs/$run_id/effect` solo después de que el endpoint de la versión activa informe de la versión solicitada. Una llamada a una API de aprovisionamiento podría usar `run_id` como valor de idempotencia. Así, un reintento SSH puede consultar a ambos sistemas por la misma unidad de trabajo.

Aquí hay una regla práctica: reintenta libremente una lectura, reintenta una creación solo con una restricción de unicidad persistente y reintenta un cambio de varias etapas solo después de que su poscondición haya clasificado la ejecución anterior. Es más lento que reenviar un comando a ciegas, pero mucho más rápido que limpiar infraestructura duplicada.

## Ejecutar el comando en segundo plano traslada el problema

`nohup`, `&`, `disown`, `tmux`, `screen` y los gestores de servicios resuelven partes distintas del problema. Ninguno convierte una solicitud remota incierta en un resultado verificado.

`nohup` ayuda a que un proceso sobreviva a una señal de desconexión en configuraciones de shell habituales. Un `nohup task &` simple sigue dejándote con archivos de salida, un PID y ningún registro estructurado de finalización, salvo que añadas uno. También da al llamador una nueva ambigüedad: ¿el shell remoto inició `nohup` o la conexión murió antes?

`tmux` y `screen` mantienen vivo un entorno interactivo. Funcionan bien cuando un operador necesita volver a conectarse e inspeccionar manualmente un comando largo. Funcionan mal como contrato de automatización porque los nombres de sesión pueden coincidir, el historial de desplazamiento no es un esquema de resultados y un terminal separado no indica a otro sistema si se produjo el efecto solicitado.

Un gestor de servicios es más sólido cuando el trabajo es realmente un servicio o un trabajo en cola. Por ejemplo, un comando remoto puede enviar una unidad con nombre y una consulta posterior puede inspeccionar su ciclo de vida y sus registros. Usa ese modelo cuando el host ya tenga un responsable operativo de los trabajos. No añadas un gestor de servicios a un comando administrativo de cinco segundos solo para evitar escribir un pequeño registro de ejecución.

La separación útil es sencilla. Mantén el trabajo interactivo de reparación en un multiplexor de terminal. Coloca las cargas programadas o de larga duración bajo un gestor de servicios. Usa un wrapper registrado para comandos imperativos cuando el llamador SSH necesite una respuesta fiable después de reconectarse.

## Los keepalives acortan la espera, pero no cierran la ventana de ambigüedad

Los keepalives del cliente OpenSSH hacen visible antes una conexión muerta. No garantizan que un comando no haya sido aceptado antes de que fallara la ruta de red.

Para hosts donde un cliente bloqueado hace perder tiempo, una configuración como esta resulta razonable:

```text
Host production-*
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive yes
```

`ServerAliveInterval` envía mensajes a nivel de aplicación por el canal SSH cifrado cuando no llegan datos. Si el cliente no recibe suficientes respuestas, sale en lugar de esperar indefinidamente. OpenSSH documenta esta función por separado de los keepalives TCP, que operan en la capa de transporte.

Usa esta configuración para limitar cuánto espera un llamador antes de iniciar la verificación. No la describas como garantía de entrega del comando. La desconexión aún puede producirse después de que el host remoto acepte el comando y antes de que el cliente reciba el resultado.

El multiplexado exige la misma cautela. `ControlMaster` y `ControlPersist` pueden reutilizar una conexión de red existente para varios comandos SSH. Esto reduce el coste de configuración, pero una conexión maestra dañada también puede afectar a varias llamadas a la vez. El manual de OpenSSH indica que una maestra persistente permanece en segundo plano después de que salga el cliente original. Es útil desde el punto de vista operativo, pero no añade evidencia de finalización a un comando enviado por ella.

Para la automatización, establece un tiempo de espera de conexión explícito, configura límites de actividad adecuados para el entorno y haz que la ruta de verificación sea independiente de la sesión SSH original. Un fallo rápido solo sirve si la siguiente acción es una consulta de estado y no un reintento a ciegas.

## Crea la prueba de fallos antes de necesitarla a las dos de la madrugada

Un protocolo que nunca has interrumpido es solo un esquema de diseño. Pruébalo con una operación segura pero lo bastante lenta para cortar la conexión en distintos puntos.

Empieza con una carga útil que escriba un archivo de progreso numerado, duerma entre etapas y escriba un marcador de efecto final. Iníciala mediante el wrapper. Mata el cliente SSH local después de que aparezca el marcador remoto `started_at`, vuelve a conectarte y ejecuta el verificador. Repite la prueba terminando la carga útil remota antes de que escriba `finished_at`. Por último, simula un reinicio del host si tu entorno lo permite.

Las clasificaciones esperadas deben ser explícitas:

- Antes de que el wrapper reclame el ID de ejecución, la verificación devuelve `absent`.
- Durante la ejecución de la carga útil, la verificación devuelve `running`.
- Después de una finalización normal, devuelve `finished` con el estado registrado.
- Después de una interrupción forzada o de la pérdida del host, devuelve `damaged`, seguida de una comprobación de la poscondición.

Prueba también el envío duplicado. Invoca dos veces el mismo ID de ejecución casi al mismo tiempo. Una llamada debe ganar la reclamación del directorio y la otra debe devolver un resultado de duplicado inequívoco sin ejecutar la carga útil. Después prueba dos IDs distintos que apunten al mismo recurso. Si eso crea una condición de carrera, el wrapper necesita un bloqueo específico del recurso o el sistema de destino necesita una regla de unicidad.

Conserva los registros de ejecución el tiempo suficiente para cubrir tu ventana operativa de reintentos. Si un trabajo puede reintentarse un día después, pero el host elimina los registros al cabo de una hora, has incorporado un temporizador a tu incertidumbre. Protege también los registros frente a modificaciones casuales. La cuenta que verifica una ejecución no debería poder editar `exit_status` ni sustituir el registro de la carga útil. En sistemas compartidos, separa los roles de remitente, ejecutor y lector cuando el modelo operativo lo permita.

Cuando los agentes de IA invocan SSH, el aislamiento de credenciales y la verificación de comandos deben trabajar juntos. Sallyport conserva las credenciales SSH en su bóveda y puede registrar la acción solicitada por un agente, mientras el wrapper remoto proporciona la respuesta persistente sobre el trabajo en sí. El registro de acciones puede decirte qué proceso pidió el comando; el ID de ejecución remoto te dice qué ocurrió después de que la conexión se volviera incierta.

## La decisión segura de reintento tiene cuatro resultados

Después de una desconexión, clasifica antes de actuar. Hay cuatro resultados útiles y solo uno de ellos es un reintento automático.

Si la ejecución es `absent`, el wrapper remoto nunca creó su registro. Puedes enviar la misma operación con el mismo ID si confías en el comportamiento de creación única del wrapper. Si el comando pudo ejecutarse fuera del wrapper, inspecciona primero el destino, porque el wrapper no puede demostrar lo que se hizo sin pasar por él.

Si la ejecución es `running`, espera o cancélala mediante una ruta de control específica de la operación. No inicies otra copia. Una política de tiempo de espera pertenece a la carga útil o al gestor de trabajos, no a una segunda invocación SSH que compita con la primera.

Si la ejecución es `finished`, evalúa su estado de salida y comprueba después la poscondición cuando la operación tenga efectos externos relevantes. Un estado de salida cero junto con una poscondición fallida significa que la operación falló. Trata la comprobación del efecto como la autoridad.

Si la ejecución es `damaged`, deja de considerarlo un problema de reintento. Es trabajo de conciliación. Inspecciona los registros, las entradas del journal, el estado del destino y cualquier registro de idempotencia. Decide si debes reparar el efecto parcial, marcar la operación como completada o enviar una nueva ejecución que gestione explícitamente el estado observado. Puede hacer falta una persona, porque la falta de registro eliminó la prueba de la que depende la automatización.

El cambio que merece la pena es pequeño: todo comando que pueda perjudicarte si se repite debe tener un ID de ejecución, un registro remoto de inicio, un registro remoto final y una comprobación del estado del destino. SSH seguirá desconectándose. Tu automatización ya no tendrá que fingir que sabe qué ocurrió.
