# Los trabajos SSH en segundo plano sobreviven a una llamada

Un estado de salida cero de una llamada SSH solo demuestra que el comando remoto informó de un resultado correcto. No demuestra que todos los procesos originados por ese comando se hayan detenido, que hayan terminado de escribir ni que hayan alcanzado un resultado satisfactorio. Si el comando inició trabajo en segundo plano, la acción que importa puede estar empezando justo cuando la herramienta registra su finalización.

Trato esa diferencia como un límite de auditoría, no como una curiosidad del shell. Un agente puede ejecutar un script de despliegue, recibir el estado 0 y seguir adelante mientras una migración separada continúa cambiando datos. La transcripción de la herramienta dice la verdad sobre el canal SSH, pero invita a sacar una conclusión equivocada sobre el trabajo remoto. La solución consiste en nombrar el ciclo de vida que se quiere observar, probarlo con el mismo shell y las mismas condiciones de terminal que usa el agente, y obtener pruebas de finalización del sistema que posee el proceso duradero.

## El cero describe el comando remoto, no sus descendientes

OpenSSH devuelve el estado de salida que proporciona el comando remoto, o 255 si el propio cliente SSH encuentra un error. RFC 4254 es aún más preciso: cuando termina el comando del otro extremo, el servidor puede enviar una solicitud de canal `exit-status` y después cerrar el canal. Ninguno de los dos documentos afirma que el servidor espere de forma recursiva a todos los descendientes del comando.

La distinción importa siempre que un shell ejecuta una lista asíncrona. POSIX define como asíncrono un comando terminado con `&`: el shell lo inicia y continúa sin esperar. Si no tiene nada más que hacer, el shell puede salir correctamente mientras el hijo asíncrono sigue vivo. El estado SSH pertenece a ese shell.

Hay al menos cuatro resultados que suelen llamarse éxito:

- La conexión SSH y la autenticación funcionaron.
- El shell remoto aceptó e inició un comando.
- La carga de trabajo iniciada terminó con estado 0.
- El efecto previsto quedó guardado y se puede observar.

Un solo entero no puede establecer los cuatro. Un registro de auditoría claro debe indicar qué evento lo produjo. Uso `ssh_command_exit_status` para el resultado del canal y reservo `workload_result` para las pruebas que comunica el responsable remoto del trabajo.

La misma advertencia se aplica cuando el ejecutable remoto se convierte en demonio. Un lanzador puede devolver 0 después de un fork correcto aunque su hijo falle segundos más tarde al abrir una base de datos, enlazar un puerto o leer una configuración. El lanzador cumplió su contrato; el auditor eligió el contrato equivocado.

## Una sonda de doce segundos deja visible la diferencia

Se puede reproducir este éxito engañoso sin un demonio, acceso root ni ajustes poco habituales del shell. Ejecuta lo siguiente con una cuenta Unix desechable. Las redirecciones explícitas importan porque permiten que el proceso en segundo plano suelte el canal SSH mientras continúa ejecutándose.

```sh
ssh testhost 'rm -f /tmp/ssh-bg.done /tmp/ssh-bg.log; (sleep 12; date -u +%FT%TZ > /tmp/ssh-bg.done) > /tmp/ssh-bg.log 2>&1 < /dev/null & printf "launcher_pid=%s\n" "$!"'
printf 'ssh_status=%s\n' "$?"
ssh testhost 'test -f /tmp/ssh-bg.done; printf "done_status=%s\n" "$?"'
sleep 13
ssh testhost 'cat /tmp/ssh-bg.done'
```

Un resultado inmediato habitual tiene esta forma:

```text
launcher_pid=41872
ssh_status=0
done_status=1
2026-07-24T10:14:05Z
```

El PID y la marca temporal serán distintos. La contradicción es precisamente la prueba: `ssh_status=0` y `done_status=1` coexisten porque responden a preguntas diferentes. El shell inició correctamente la lista asíncrona, pero el archivo de marca todavía no existía.

No conviertas este ejemplo en un sistema de producción. Los archivos de marca en `/tmp` pueden colisionar, desaparecer o ser falsificados por otro proceso con acceso suficiente. La sonda es útil porque hace visible el orden temporal. Un registro de finalización de producción necesita un identificador de ejecución único, almacenamiento protegido, un escritor autenticado y un estado de fallo definido.

Repite la sonda por la ruta exacta que utiliza el agente. Un comando directo en terminal, una solicitud exec SSH no interactiva, una llamada SSH con seudoterminal y una pasarela de herramientas pueden elegir archivos de inicio, shells y disposiciones de descriptores diferentes. Una prueba que omite esos detalles ha probado un sistema vecino.

## Los descriptores abiertos pueden aparentar sincronía

Ejecutar en segundo plano y cerrar el canal son mecanismos distintos. Un hijo que hereda la salida estándar o el error estándar del canal SSH puede mantener el canal legible después de que salga el shell remoto. El proceso local `ssh` puede parecer que espera al hijo porque la tubería aún no ha llegado al fin de archivo, no porque SSH supervise el resultado del hijo.

Compara estas dos llamadas y mide el tiempo transcurrido:

```sh
time ssh testhost 'sleep 12 &'
time ssh testhost 'sleep 12 > /tmp/sleep.log 2>&1 < /dev/null &'
```

Con combinaciones habituales de OpenSSH y shell, la primera llamada puede quedar abierta hasta que termine `sleep`, mientras que la segunda vuelve enseguida. Hay que verificarlo como observación, no tratarlo como una promesa portable. La implementación del shell, el comportamiento del servidor, la asignación de seudoterminal y el manejo de descriptores del programa hijo pueden cambiar el resultado.

Esta espera accidental es una prueba débil. El proceso en segundo plano puede cerrar pronto sus descriptores y seguir trabajando. Puede crear un nieto que los cierre. Puede enviar la salida por un socket o escribir directamente en almacenamiento. A la inversa, un auxiliar que solo mantiene stdout abierto puede hacer que la llamada parezca ocupada después de que el trabajo importante haya fallado.

Aun así, conviene inspeccionar los descriptores porque explican muchas pruebas inconsistentes. En Linux, captura el PID remoto e inspecciónalos mientras la llamada SSH siga activa:

```sh
pid=$(cat /run/user/$(id -u)/agent-job.pid)
ps -o pid=,ppid=,pgid=,sid=,stat=,etime=,args= -p "$pid"
ls -l "/proc/$pid/fd/0" "/proc/$pid/fd/1" "/proc/$pid/fd/2"
```

Registra el PID padre, el grupo de procesos, el ID de sesión, el estado, el tiempo transcurrido, el comando y los destinos de los descriptores 0, 1 y 2. Si `/proc` no está disponible, usa las herramientas nativas del sistema para procesos y descriptores. No reduzcas la prueba a `pgrep name`: los nombres colisionan, los envoltorios los cambian y un PID puede reutilizarse tras una salida.

## nohup resuelve el cuelgue, no la propiedad

`nohup` cambia el manejo de señales para que el comando invocado ignore SIGHUP. No coloca el comando en segundo plano. El manual de GNU Coreutils lo dice expresamente e indica que se añada `&` para la ejecución asíncrona. Esa condición suele perderse al copiar fragmentos de despliegue.

Sus reglas de redirección también sorprenden por SSH. GNU `nohup` redirige la entrada estándar solo si es un terminal, envía la salida estándar a `nohup.out` solo si stdout es un terminal y normalmente toma la misma decisión para el error estándar. Un comando SSH no interactivo suele usar tuberías, por lo que `nohup` puede dejar esos descriptores conectados al canal SSH.

Por eso, estos dos comandos prometen cosas distintas:

```sh
ssh testhost 'nohup /opt/jobs/rebuild-index &'
ssh testhost 'nohup /opt/jobs/rebuild-index > /var/log/rebuild-index.log 2>&1 < /dev/null &'
```

La segunda forma desconecta expresamente los descriptores estándar. Aun así, no dice si `rebuild-index` terminó. `nohup` informa de fallos de invocación, como un comando ausente, y en los demás casos su estado sigue al comando invocado. Una vez que el shell lo pone en segundo plano, normalmente informa de que inició la tarea, no de su estado final.

La inmunidad a SIGHUP solo cubre una parte de la supervivencia. El proceso puede morir porque un gestor de inicio de sesión elimina la sesión, un gestor de servicios mata su grupo de control, el kernel aplica una política por falta de memoria, un administrador revoca la cuenta o el host se reinicia. También puede sobrevivir y producir un resultado incorrecto. `nohup` no aporta identidad, reintentos, límites de recursos, estado duradero ni un registro de finalización fiable.

Sigo usando `nohup` para mantenimiento pequeño y desechable cuando puedo aceptar perder el resultado y estoy observando el host. No lo uso para convertir la llamada SSH de un agente en un trabajo de producción gestionado. La recomendación es popular porque el fragmento es corto y suele sobrevivir a la pérdida de terminal. Es equivocada cuando alguien debe demostrar más tarde qué terminó.

## El control de tareas cambia con un terminal

El control de tareas del shell agrupa procesos para que un usuario interactivo pueda suspender, reanudar, llevar al primer plano y enviar al segundo plano tuberías. Un shell no interactivo suele ejecutarse sin modo monitor, y una solicitud exec SSH no suele tener seudoterminal salvo que el cliente lo pida. Los scripts que dependen de `jobs`, `%1`, `disown` o señales del terminal pueden comportarse de otro modo al ejecutarlos un agente.

POSIX limita los identificadores de tarea y los PID de segundo plano conocidos al entorno de ejecución del shell actual. Su utilidad `wait` puede esperar esos procesos conocidos, pero un `wait` iniciado en otro shell no hereda la tabla de tareas. Esto falla como técnica de auditoría:

```sh
ssh testhost 'long_task & printf "%s\n" "$!"'
ssh testhost 'wait 41872; printf "wait_status=%s\n" "$?"'
```

La segunda llamada inicia un shell nuevo. Aunque 41872 siga vivo, ese shell no lo conoce como hijo. POSIX especifica 127 para un PID desconocido pasado a `wait`. Los permisos y la reutilización de PID hacen aún menos fiable cualquier intento de reconstruir la relación.

Mantén el inicio y la espera en el mismo shell cuando el contrato exija finalización síncrona:

```sh
ssh testhost 'long_task > /tmp/long-task.log 2>&1 < /dev/null & pid=$!; printf "pid=%s\n" "$pid"; wait "$pid"; rc=$?; printf "workload_status=%s\n" "$rc"; exit "$rc"'
```

Este patrón devuelve el estado del hijo y mantiene abierta la acción SSH. Funciona para un hijo que permanece unido a ese shell. Si `long_task` crea otro proceso y el original sale, `wait` puede acabar antes que el trabajador real. Prueba el ejecutable real, no un `sleep` sustituto, antes de aceptar el contrato.

Los seudoterminales añaden señales y cambios de búfer. Un terminal puede enviar SIGHUP al terminar su sesión, y los grupos en segundo plano que leen del terminal de control pueden recibir SIGTTIN y detenerse. Algunos programas cambian al búfer por líneas o producen otra salida cuando detectan un terminal. Salvo que el comando necesite esa semántica, la automatización no debería asignar un terminal y debería configurar los tres descriptores estándar de forma deliberada.

## setsid separa un proceso, pero no aporta pruebas

`setsid` crea una sesión y un grupo de procesos nuevos, al principio sin terminal de control. Es una separación del terminal más fuerte que ignorar solo SIGHUP. Explica por qué un hijo puede sobrevivir al shell y por qué las señales del terminal dejan de seguirlo.

No convierte el proceso en uno supervisado. Cuando sale el padre original, otro proceso puede adoptar al descendiente. En un host convencional puede ser PID 1; dentro de un contenedor o árbol de servicios puede ser un subreaper. La nueva relación parental no dice nada sobre el resultado del trabajo y puede borrar el vínculo más sencillo con la acción que lo inició.

Una prueba útil captura la identidad antes de que desaparezca el shell:

```sh
ssh testhost 'run_id=agent-probe-20260724-1014; setsid sh -c '\''printf "%s\n" "$$" > /tmp/'"$run_id"'.pid; sleep 12; printf "complete\n" > /tmp/'"$run_id"'.state'\'' > /tmp/'"$run_id"'.log 2>&1 < /dev/null & printf "run_id=%s\n" "$run_id"'
```

Después consulta por el ID devuelto, usa el PID guardado solo como pista y verifica la hora de inicio y el comando antes de actuar. Un PID solo no es una identidad duradera. Si el proceso sale y el kernel reutiliza el número, una orden de limpieza posterior puede afectar a trabajo ajeno.

El doble fork, `setsid`, `disown` y el cierre de descriptores son técnicas de implementación. Los equipos suelen confundirlos con un protocolo de tareas porque hacen que vuelva el terminal. Un protocolo responde otras preguntas: ¿quién posee ahora el trabajo?, ¿cómo se consulta?, ¿qué estados terminales existen?, ¿dónde está el motivo de salida?, ¿cómo se cancela todo el árbol?, ¿qué identificador une solicitud, registros, efectos y auditoría?

Si la respuesta de lanzamiento no puede contestarlas, regístrala como inicio separado, no como acción completada.

## Define tres eventos de ciclo de vida en el contrato

Una acción remota auditable necesita eventos distintos para aceptación, final del canal y final de la carga de trabajo. Reducirlos a un booleano `success` crea una certeza falsa y hace que la reconstrucción dependa del historial del shell.

Uso un registro con esta forma conceptual:

```json
{
  "action_id": "act_01J3M8Q4",
  "remote_host": "worker-07",
  "launch": {"state": "accepted", "at": "2026-07-24T10:14:00Z"},
  "ssh_command": {"state": "exited", "status": 0, "at": "2026-07-24T10:14:01Z"},
  "workload": {"id": "job_8931", "state": "running", "result": null},
  "completion_source": "remote-job-manager"
}
```

Los nombres importan menos que la separación. `accepted` significa que el responsable remoto validó y aceptó la solicitud. `exited` indica que terminó el comando SSH. `running` dice que el trabajo duradero aún no llegó a un estado terminal. Solo el componente que posee la carga debe escribir `succeeded`, `failed` o `cancelled` para ella.

Haz explícitas las reglas de transición. Un lanzamiento puede fallar antes de crear un ID. El canal puede romperse después de que el sistema remoto acepte el trabajo, dejando al llamador en estado incierto, no fallido. La carga puede fallar tras una salida limpia del canal. La cancelación puede estar solicitada pero pendiente. Un modelo que no representa `unknown` acabará registrando una conjetura como hecho.

La idempotencia pertenece al contrato. Si el cliente pierde el canal tras enviar, debe reintentar con el mismo ID de acción y preguntar si el responsable ya lo aceptó. Iniciar una segunda migración porque se perdió la primera respuesta es peor que tener un registro desordenado.

La prueba de finalización debe incluir ID, estado terminal, motivo de salida, horas de inicio y fin, e identidad del gestor que observó el estado. Añade pruebas específicas del efecto cuando el riesgo lo justifique, como una revisión desplegada, un manifiesto de copia completo o una versión de esquema. No uses una línea con `done` como única autoridad salvo que el registrador y el almacenamiento formen parte del protocolo fiable.

## Prueba las ventanas de fallo, no solo el caso feliz

Una matriz útil varía la forma de separación, el destino de los descriptores y el momento en que fallan conexión o proceso. Ejecútala en cada clase de host, porque los gestores de sesión y servicio y los shells cambian el comportamiento.

Cubre al menos estos casos:

- Comando en primer plano, tarea de shell en segundo plano, `nohup` con segundo plano, sesión nueva mediante `setsid` y programa que se convierte en demonio.
- Sin terminal y con seudoterminal asignado.
- Descriptores heredados, redirigidos a archivos y cerrados por el hijo.
- Desconexión antes de aceptar, después de aceptar pero antes de responder y después de salir el comando SSH.
- Hijo que sale con error, recibe una señal, queda bloqueado, crea un nieto y sobrevive hasta la cancelación explícita.

Para cada caso, captura cuatro relojes: inicio del cliente, confirmación, cierre del canal y estado terminal remoto. Guarda por separado el estado SSH y el resultado de la carga. Inspecciona grupo y sesión mientras se ejecuta y demuestra si la cancelación alcanza a todos los descendientes.

Un arnés compacto puede fallar cuando llega un estado 0 sin prueba terminal:

```sh
result=$(ssh testhost '/usr/local/bin/job-submit agent-probe-42')
ssh_rc=$?
printf 'ssh_rc=%s response=%s\n' "$ssh_rc" "$result"
job_id=$(printf '%s\n' "$result" | sed -n 's/^job_id=//p')
test "$ssh_rc" -eq 0 && test -n "$job_id" || exit 1
/usr/local/bin/poll-job "$job_id" || exit 1
```

El ejemplo presupone que `job-submit` devuelve una sola línea `job_id=` y que `poll-job` autentica la consulta, espera un estado terminal y sale con el resultado real. Son requisitos del contrato, no propiedades de SSH. En producción, rechaza salida adicional, impone un plazo, conserva el estado incierto al vencer y guarda la respuesta bruta.

Prueba también al observador. Detén el agente después del lanzamiento. Reinicia el cliente. Rota la credencial SSH. Reinicia el host remoto si el trabajo debe sobrevivir. Si el único registro del ID está en la ventana de contexto de un agente, el sistema no es auditable.

## Un gestor de servicios suele ser el propietario adecuado

Cuando el trabajo debe sobrevivir al comando SSH, entrégalo a un servicio o gestor de tareas remoto y devuelve su identificador duradero. El gestor debe poseer el grupo, recopilar salida, aplicar recursos y cancelación, conservar estado y exponer una consulta que diferencie ejecución de estado terminal.

En un host systemd, un servicio transitorio o de plantilla aporta grupo de control e identidad de journal. El manual de systemd-run distingue el arranque asíncrono de esperar a que termine el servicio, y advierte que un servicio simple puede considerar correcto el arranque tras el fork, antes de ejecutar el programa. Prefiero `Type=exec` para hacer visible un fallo de ejecución, aunque eso todavía prueba el inicio, no el éxito final.

Una unidad de plantilla puede fijar el límite de propiedad así:

```ini
[Unit]
Description=Agent job %i

[Service]
Type=exec
ExecStart=/usr/local/libexec/agent-job %i
StandardOutput=journal
StandardError=journal
KillMode=control-group
TimeoutStopSec=30s
```

Envía un ID único y validado y consulta la unidad hasta el estado terminal. Registra `ActiveState`, `SubState`, `Result`, `ExecMainStatus`, las horas y el ID. Confirma el comportamiento cuando la carga hace fork, porque tipo de servicio y programa deben coincidir. No conviertas entrada arbitraria en nombre de unidad ni argumento sin validación estricta.

Una cola, planificador por lotes, orquestador de contenedores o tabla propia puede ofrecer el mismo límite. Elige al responsable que ya controla recursos y recuperación. SSH debe enviar y consultar, no fingir ser un planificador mediante una cadena complicada de operadores de shell.

Para trabajo corto, mantener el comando en primer plano y devolver su estado real es más sencillo. Separar cuesta otro almacén, otra identidad, semántica de cancelación, retención y reconciliación. Paga ese coste solo si el trabajo debe sobrevivir a la llamada.

## La auditoría termina en el estado terminal remoto

Una pasarela puede registrar con precisión la llamada SSH sin saber que existe un descendiente remoto. Sallyport registra la acción SSH en Activity journal y la ejecución del agente en Sessions journal, por lo que la entrada es prueba del resultado del canal, no un censo de procesos del host. El ID remoto y su evento terminal deben volver mediante una acción explícita y auditable.

Esa división mantiene honestos los registros. La pasarela prueba qué ejecución invocó SSH, qué clave protegida autorizó, qué llamada ocurrió y qué resultado volvió. El gestor remoto prueba lo sucedido después. Une ambos con un ID de acción que el agente no pueda sustituir entre inicio y consulta.

No marques un inicio separado como `completed`. Usa `submitted` o `detached`, muestra el ID y mantén la acción principal abierta o pendiente hasta que un observador fiable registre el estado terminal. Si vence la observación, muestra `unknown` y exige reconciliación. Un estado rojo puede molestar, pero uno verde basado en el proceso equivocado es peligroso.

El momento de aprobación requiere la misma precisión. Aprobar el uso de una clave SSH autoriza un intento según la información mostrada entonces. El clic no aprueba toda acción futura de descendientes sin límite ni certifica el efecto final. Si una tarea puede durar horas o crear procesos, indícalo antes y vincula la aprobación con ID, host, intención y tipo remoto. La aprobación y la finalización van en la misma cadena, pero describen decisiones distintas.

Conserva la salida bruta en cada límite. Guarda la respuesta antes de analizarla, captura stderr por separado cuando sea posible y registra si un seudoterminal mezcló los flujos. El analizador debe rechazar ID duplicados, caracteres de control, respuestas truncadas y líneas extra que confundan respuestas. Los campos analizados sirven a la automatización; los bytes brutos permiten revisar fallos del analizador, las comillas del shell o el programa remoto. Ninguna forma debe contener secretos.

Una marca de finalización necesita publicación atómica. El trabajador debe escribir en un archivo temporal protegido, volcar datos cuando importe la durabilidad y renombrarlo solo al completar el registro. La consulta debe validar ID, propietario, tipo de archivo y estado. Mejor aún, un gestor o base de datos debe exponer estado por una interfaz autenticada. Una marca globalmente escribible en `/tmp` muestra tiempos, pero no resuelve un incidente.

Planifica entrega incierta al enviar y cancelar. Si la conexión desaparece después de aceptar pero antes de recibir el ID, el estado local correcto es `unknown`. Reconecta con la clave idempotente original y busca ese envío. No repitas en silencio. Si se pierde la respuesta de cancelación, consulta hasta que el gestor informe de un estado terminal y de un grupo vacío. Enviar una señal es un intento, no prueba de detención.

La reconciliación debe sobrevivir al agente. Guarda ID no resueltos fuera de la conversación, asigna un responsable y consulta periódicamente para cerrar o escalar registros antiguos. Define plazo y fallo por separado: una tarea puede superar la espera y seguir sana y controlada. El registro debe mostrar cuándo dejó de esperar el cliente, quién observa y si llegó el final. De lo contrario, un tiempo agotado se convierte en otro estado terminal falso.

Los revisores necesitan el vocabulario del sistema. Busca acciones con `ssh_command.status` cero y `workload.state` ausente, atrasado o desconocido. Busca tareas terminales sin aprobación de inicio y envíos repetidos con la misma clave idempotente. Esas consultas convierten la distinción en un control que encuentra huecos.

Da al operador una acción para reabrir el registro remoto. La vista debe mostrar la última observación, su componente y si procede de una consulta en vivo o de caché. No cambies `unknown` a `failed` para vaciar una cola. Conserva la incertidumbre hasta que responda el responsable o un revisor autorizado la resuelva con pruebas documentadas. Los registros abiertos tras una caída son incómodos y exactos.

Prueba también la retención. Los registros deben poder consultarse más que la tarea prevista y durante la auditoría o investigación. Si el gestor elimina enseguida una unidad transitoria, copia su resultado terminal al registro duradero antes de recogerla. Guardar un ID que ningún sistema pueda resolver la semana siguiente aporta correlación sin responsabilidad.

Ejecuta la sonda de doce segundos por la ruta real del agente y repítela con la carga importante. Si la tarjeta SSH se vuelve verde antes de existir la marca remota, has encontrado un hueco de auditoría. Conserva el cero, porque es una prueba válida del comando. Deja de pedirle que testifique sobre trabajo que nunca observó.
