From 7549dc86d215149a22dcb43e0eef061d098b3cc8 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 26 Sep 2026 12:39:30 +0000 Subject: [PATCH] docs(playbooks): incidente cadena de 4 fallos en tmux-host (2026-09-25/26) Documenta la cadena de causas encontradas tras eliminar tmux-host por "no toma las sesiones remotas": volumenes de auth faltantes al recrear, set -e matando al supervisor del healthcheck, IPv6 roto en el NAS (timeout a Anthropic) y, como causa original, el token OAuth vencido. Agrega la fila de expiracion OAuth a "Que sobrevive a que" en sesion-tmux-persistente.md con enlace cruzado al playbook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP --- .../arquitectura/sesion-tmux-persistente.md | 8 + ...ente-tmux-host-cadena-fallos-2026-09-25.md | 324 ++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + 3 files changed, 333 insertions(+) create mode 100644 workloads/docs-portal/docs/playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md diff --git a/workloads/docs-portal/docs/arquitectura/sesion-tmux-persistente.md b/workloads/docs-portal/docs/arquitectura/sesion-tmux-persistente.md index 611b73c..cd1e288 100644 --- a/workloads/docs-portal/docs/arquitectura/sesion-tmux-persistente.md +++ b/workloads/docs-portal/docs/arquitectura/sesion-tmux-persistente.md @@ -408,6 +408,14 @@ después del swap. | `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` | Sí, desde el 2026-09-20 — `--continue` retoma la conversación más reciente en `WORKDIR` (ver gotcha arriba) | | `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) | Sí, mismo mecanismo — validado en vivo durante el swap de imagen del 2026-09-20 | | Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) | N/A | +| Expiración del token OAuth de Claude Code | Sí — el proceso `claude` sigue vivo en el pane, pero muestra `Login expired` y Remote Control queda desconectado (`OAuth token unavailable`) | **No** — requiere `/login` manual (una vez alcanza: el volumen de credenciales es compartido) y después `/remote-control` en **cada** sesión ya abierta. Recrear el contenedor **no** lo arregla | Sí, una vez reautenticado — misma sesión, mismo proceso | + +!!! warning "Incidente relacionado" + La fila de expiración del token OAuth salió del + [incidente de la cadena de 4 fallos en `tmux-host` (2026-09-25/26)](../playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md). + El síntoma "no aparecen las sesiones remotas" llevó a eliminar el + contenedor, aunque la causa real era un token vencido. **Antes de + tocar el contenedor, mirá el pane de cada sesión.** ## Validación en vivo diff --git a/workloads/docs-portal/docs/playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md b/workloads/docs-portal/docs/playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md new file mode 100644 index 0000000..86a5df7 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md @@ -0,0 +1,324 @@ +# Incidente: cadena de 4 fallos en `tmux-host` (2026-09-25/26) + +| | | +|---|---| +| **Ventana del incidente** | Noche del 2026-09-25 al 26 (hora local); en `health.log`, de 2026-09-26 04:11Z a ~06:20Z | +| **Servicio afectado** | Contenedor `tmux-host`: las sesiones `lab` y `cloud-practice` de Claude Code y su Remote Control desde claude.ai (ver [Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)) | +| **Impacto** | Las sesiones remotas no aparecían en claude.ai. Mientras se intentaba arreglar, se perdió el login y la confianza del folder, el supervisor del healthcheck quedó muerto y `claude` no podía conectarse a Anthropic | +| **Disparador** | Se eliminó el contenedor `tmux-host` a mano creyendo que era la causa | + +## Contexto / disparador + +El síntoma inicial era uno solo: **"no toma las sesiones remotas"**. Las +sesiones de Claude Code dentro de `tmux-host` no aparecían en claude.ai. +Parecía un problema del contenedor, así que se eliminó `tmux-host` para +recrearlo desde cero. + +En realidad ese síntoma tenía una causa que no tenía nada que ver con el +contenedor: el token OAuth estaba vencido (causa **d**, más abajo). Al +recrear el contenedor, la mitad de la noche se fue en resolver tres fallos +reales que *aparecieron* en el camino (causas **a**, **b** y **c**). Cada +uno se encontró recién después de resolver el anterior. Recién al final +apareció la causa original. + +!!! info "Fuentes de la evidencia" + Este playbook se escribió desde dentro del `tmux-host` ya recuperado. + Los datos marcados **(verificado)** se confirmaron contra el estado + real del sistema al escribirlo: `docker inspect`, `docker images`, + `health.log`, `sysctl`, `curl`, `git diff` y los panes de tmux. Los + marcados **(reportado)** vienen del relato de la sesión de + recuperación. Esa sesión se hizo desde fuera del contenedor y no dejó + transcript ni historial de shell que se pueda revisar. + +--- + +## Causa a — Contenedor recreado sin los volúmenes de autenticación + +**Síntoma.** Al recrear `tmux-host`, `claude` volvía a pedir login y el +diálogo de confianza del folder. `--continue` no encontraba ninguna +conversación previa y arrancaba una en blanco. + +**Diagnóstico.** El `docker run` que se usó para recrear el contenedor +se armó de memoria. Tenía solo 3 de los 5 volúmenes documentados en +[Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md) +**(reportado)**. Faltaban justamente los dos de autenticación y estado +de Claude Code: + +| Volumen faltante | Qué se perdía | +|---|---| +| `/DATA/AppData/k3d-lab-v3/claude-code-home` → `/root/.claude` | Credenciales OAuth, historial de conversaciones (por eso `--continue` no encontraba nada), plugins y memoria | +| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` → `/root/.claude.json` | Confianza del folder y el flag de Remote Control | + +Sin esos mounts, `/root/.claude*` vive en el filesystem efímero del +contenedor, así que cada recreación arrancaba "de fábrica". El contenedor +se reconstruyó varias veces con el mismo comando incompleto antes de +comparar contra el doc de arquitectura **(reportado)**. `health.log` +muestra las sesiones recreándose desde cero a las 04:11Z, 04:40Z, 04:49Z, +05:03Z, 05:18Z, 06:08Z y 06:10Z **(verificado)**. + +**Fix.** Recrear el contenedor con el `docker run` completo de la +sección *Contenedor: `tmux-host`* del doc de arquitectura. Los 5 volúmenes +van juntos, sin excepciones. + +**Validación en vivo (verificado).** `docker inspect tmux-host` sobre el +contenedor final (creado `2026-09-26T06:10:55Z`, imagen +`tmux-host:2026-09-26-fix-real`) lista los 5 mounts: + +``` +/var/run/docker.sock -> /var/run/docker.sock +/DATA/AppData/k3d-lab-v3/claude-code-home -> /root/.claude +/DATA/AppData/k3d-lab-v3/claude-code-home.json -> /root/.claude.json +/DATA -> /DATA +/media/HDD-Storage -> /media/HDD-Storage +``` + +La sesión `lab` retomó la conversación anterior con `--continue`, sin +pedir de nuevo la confianza del folder. + +!!! warning "Lección" + **Antes de improvisar un `docker run` de memoria, leer siempre el doc + de arquitectura que ya existe.** El comando completo y el motivo de + cada volumen ya estaban documentados en `sesion-tmux-persistente.md`. + El tiempo perdido reconstruyendo con un comando incompleto salió de no + abrirlo primero. + +--- + +## Causa b — `set -e` heredado mataba al supervisor del healthcheck + +**Síntoma.** `healthcheck.sh` corre en background y no tenía quién lo +relanzara si moría. Para cubrir eso se agregó a `entrypoint.sh` un +supervisor: un loop que lo relanza tras 10 s. Aun así, cuando +`healthcheck.sh` terminaba con error, no se relanzaba y el contenedor +seguía vivo sin healthcheck. + +**Diagnóstico.** `entrypoint.sh` tenía `set -eu` desde antes. El +supervisor corre como función en un subshell (`supervise_healthcheck &`) +y hereda el `-e`. Cuando `healthcheck.sh` devolvía un código distinto de +cero, `set -e` mataba al propio subshell del supervisor *en esa misma +línea*, antes de llegar al `echo WARN` y al `sleep 10` que lo relanzaban. +Es decir, el supervisor moría justo en el único caso para el que existía. + +**Paso en falso: el fix que nunca llegó al disco.** La primera +"corrección" (`set -eu` → `set -u`) nunca se guardó en el archivo, y la +imagen se reconstruyó dos veces con el contenido viejo **(reportado)**. +La evidencia sigue en el host **(verificado)**: las etiquetas +`tmux-host:2026-09-25-supervised` y `tmux-host:2026-09-25-supervised-v2` +apuntan a la **misma imagen, `ad7f6eff9537`**. Docker reutilizó todas las +capas porque el archivo no había cambiado. Recién después de confirmar +con `cat entrypoint.sh` que el cambio no estaba, se guardó de verdad y se +construyó `tmux-host:2026-09-26-fix-real` (`7e4c94bf0bc8`, 05:03Z). + +**Fix.** En `scripts/tmux-host/entrypoint.sh`: + +```diff +-set -eu ++set -u + +-/usr/local/bin/healthcheck.sh & ++supervise_healthcheck() { ++ while true; do ++ /usr/local/bin/healthcheck.sh ++ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN: healthcheck.sh salio inesperadamente (exit=$?) -- relanzando en 10s" \ ++ >> /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log 2>/dev/null || true ++ sleep 10 ++ done ++} + ++supervise_healthcheck & + exec tail -f /dev/null +``` + +**Validación en vivo (verificado).** `health.log` registra el supervisor +actuando en un caso real. `healthcheck.sh` murió por SIGKILL, se registró +el aviso y se relanzó solo 10 segundos después: + +``` +2026-09-26T05:04:17Z WARN: healthcheck.sh salio inesperadamente (exit=137) -- relanzando en 10s +[2026-09-26T05:04:27Z] === healthcheck inicio (intervalo=1800s) === +``` + +!!! warning "Lección" + **Verificar el archivo en disco (`cat`, `git diff`) ANTES de + reconstruir la imagen.** No hay que asumir que el editor guardó el + cambio. Dos etiquetas de imagen con el mismo ID indican que el build + no tuvo cambios. + +--- + +## Causa c — IPv6 roto en el NAS: timeout de conexión a Anthropic + +**Síntoma.** Con el contenedor ya bien montado, `claude` fallaba al +arrancar con `Unable to connect to Anthropic services` a los ~10 s. + +**Diagnóstico** con `curl -v https://api.anthropic.com/` **(reportado)**: + +1. El resolver devolvía primero la dirección IPv6 de `api.anthropic.com`. +2. La ruta IPv6 del NAS no sale a internet: el intento fallaba al + instante con `Network unreachable`. +3. El fallback automático a IPv4 terminaba funcionando, pero la conexión + completa tardaba **~14 s**. Eso es más que el timeout interno de + Claude Code (~10 s), así que el CLI se rendía antes de que el + fallback llegara a conectar. + +**Primer intento: parche parcial.** +`NODE_OPTIONS=--dns-result-order=ipv4first` le indica a Node que prefiera +IPv4 al resolver. Ayudó, pero no resultó confiable en todos los casos +**(reportado)**, y además solo corregía el proceso de Node, no la red del +host. Se descartó y no quedó en la imagen: el contenedor final no tiene +`NODE_OPTIONS` en su entorno **(verificado)**. + +**Fix de raíz.** Deshabilitar IPv6 a nivel de sistema operativo del NAS, +en caliente y persistente: + +```bash +# En caliente +sysctl -w net.ipv6.conf.all.disable_ipv6=1 +sysctl -w net.ipv6.conf.default.disable_ipv6=1 + +# Persistente: /etc/sysctl.conf del host +net.ipv6.conf.all.disable_ipv6 = 1 +net.ipv6.conf.default.disable_ipv6 = 1 +``` + +Esto se tiene que hacer **en el host, no dentro del contenedor**. +`tmux-host` corre con `--network host`, así que comparte el network +namespace del NAS. Docker no permite fijar sysctls de red +(`--sysctl net.*`) en un contenedor con `--network host`, porque +cambiarían la red de todo el host. + +**Validación en vivo (verificado, 2026-09-26 ~06:40Z):** + +``` +$ sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6 +net.ipv6.conf.all.disable_ipv6 = 1 +net.ipv6.conf.default.disable_ipv6 = 1 + +$ getent ahosts api.anthropic.com +160.79.104.10 STREAM api.anthropic.com # solo IPv4, sin AAAA + +$ curl -w 'connect=%{time_connect}s total=%{time_total}s' https://api.anthropic.com/ +ip=160.79.104.10 connect=0.035s total=0.65s # antes: ~14 s +``` + +`/etc/sysctl.conf` en el host contiene las dos líneas (escrito +2026-09-26 05:55:53Z). En ZimaOS, `/` es un `squashfs` de solo lectura, +pero `/etc` es un overlay con capa de escritura en +`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo +persiste. + +!!! note "Pendiente de validar" + Todavía no se probó que el sysctl persistente se aplique después de un + **reinicio real del NAS**. Tampoco se sabe si una actualización de + ZimaOS pisa `upper_etc`. En el próximo reinicio, revisar + `sysctl net.ipv6.conf.all.disable_ipv6` antes de dar por cerrado este + punto. + +--- + +## Causa d — Token OAuth vencido: Remote Control desconectado sin recuperación automática + +**Síntoma.** Con todo lo anterior resuelto, las sesiones remotas +*seguían* sin aparecer en claude.ai. Este era el síntoma original de la +noche. + +**Diagnóstico (verificado).** Ambos procesos `claude --remote-control +--continue` estaban corriendo (`ps`), y el healthcheck había recreado +las dos sesiones sin problema (`health.log`, 06:10:58Z). Así que el +auto-arranque funcionaba. El contenido de los panes de tmux mostró la +causa: + +- Sesión `lab`: `Login expired · Please run /login` +- Sesión `cloud-practice`: + `Remote Control disconnected — OAuth token unavailable — run /login to restore Remote Control` + +El token OAuth de Claude Code había vencido. No había ningún problema de +red, de contenedor ni de volúmenes. **Esta causa no tenía relación con +ninguna de las tres anteriores.** Eliminar el contenedor nunca podría +haberla resuelto. + +**Fix.** + +1. `/login` en **una** sesión. Las credenciales viven en + `/root/.claude/.credentials.json`, que es el volumen compartido por + todas las sesiones del contenedor. +2. `/remote-control` → **1. Enable Remote Control** en **cada** sesión ya + abierta. Un proceso `claude` que ya estaba corriendo **no** se + reconecta solo al renovarse el token. Hay que reactivarlo en cada uno. + Para una sesión a la que no estás adjunto: + + ```bash + tmux send-keys -t cloud-practice '/remote-control' Enter + # aparece el menú con "1. Enable Remote Control" ya seleccionado + tmux send-keys -t cloud-practice Enter + ``` + +**Validación en vivo (verificado).** El pane de `cloud-practice` pasó a +mostrar `/remote-control is active · Continue here, on your phone, or at +https://claude.ai/code/session_…`, y en `lab` se activó con +`/remote-control` después del `/login`. Las dos sesiones volvieron a +aparecer en claude.ai. + +!!! tip "Diferencia clave con las otras causas" + El healthcheck **sí** recrea sola una sesión tmux caída, y `--continue` + retoma la conversación. Un **token vencido no se recupera solo**: hace + falta que una persona se reautentique (`/login`) y reactive Remote + Control (`/remote-control`) en cada sesión abierta. Recrear el + contenedor no lo arregla. Con los volúmenes bien montados, el + contenedor nuevo lee el mismo token vencido. + +--- + +## Cronología resumida + +Horas en UTC, según `health.log`, `docker images` y los timestamps de +archivos cuando existen. + +| Hora (UTC) | Qué pasó | ¿Resolvió el síntoma original? | +|---|---|---| +| antes de 04:11 | Remote Control se desconecta porque vence el token OAuth (causa **d**, todavía sin identificar) | — | +| ~04:10 | Se elimina `tmux-host` creyendo que es la causa | No | +| 04:11 → 05:18 | Varias recreaciones con un `docker run` de memoria, sin los mounts de auth: se pide login y confianza de nuevo y `--continue` arranca vacío (causa **a**) | No, y además empeoró | +| 04:39 | Se agrega el supervisor del healthcheck; builds `supervised` y `supervised-v2`, **misma imagen `ad7f6eff9537`** (el fix de `set -e` no estaba guardado) | No | +| 05:03 | Se confirma con `cat`, se guarda de verdad y se construye `fix-real` (`7e4c94bf0bc8`) con `set -u` (causa **b**) | No | +| 05:04 | El supervisor relanza `healthcheck.sh` tras un `exit=137`: primera prueba real | No | +| hasta ~05:55 | `claude` falla con `Unable to connect to Anthropic services`; con `curl -v` se ve IPv6 unreachable y ~14 s de fallback; se prueba `NODE_OPTIONS` (parcial) | No | +| 05:55 | Se deshabilita IPv6 en el host, en caliente y en `/etc/sysctl.conf` (causa **c**) | No | +| 06:10 | Contenedor final: `fix-real` + los 5 volúmenes, recreado según el doc de arquitectura (causa **a** cerrada) | No: Remote Control sigue desconectado | +| ~06:20 | Los panes muestran `Login expired` / `OAuth token unavailable` → `/login` + `/remote-control` en cada sesión (causa **d**) | **Sí** | + +**Camino corto para la próxima vez.** Si "no aparecen las sesiones +remotas", **antes de tocar el contenedor** mirar el pane de cada sesión: + +```bash +for s in $(tmux ls -F '#S'); do echo "== $s"; tmux capture-pane -p -t "$s" | grep -v '^\s*$' | tail -5; done +``` + +Si aparece `Login expired` u `OAuth token unavailable`, la solución es +`/login` + `/remote-control`. No hace falta nada más. + +## Lección general + +Un solo síntoma ("no toma las sesiones remotas") puede tener **varias +causas independientes superpuestas**. En este caso, tres de ellas las +provocó el propio intento de arreglo. Que la primera causa real +encontrada se haya resuelto no significa que era *la* causa del síntoma. + +Lo que destrabó el incidente fue seguir verificando con evidencia +concreta después de cada fix, en vez de asumir que "ya quedó": el +contenido de los panes, `health.log`, `docker inspect`, `docker images` +(IDs repetidos) y `curl -v`. Cada una de las cuatro causas se confirmó +con un dato observable, no por deducción. + +## Pendientes + +- [ ] **Commitear el fix de `entrypoint.sh`.** La imagen `fix-real` que + corre en producción lo tiene, pero en el repo `scripts` el cambio + sigue como modificación local sin commitear (`git status` → + `M tmux-host/entrypoint.sh`). Si alguien reconstruye la imagen + desde `main`, vuelve el `set -eu` sin supervisor. +- [ ] Validar que IPv6 siga deshabilitado después del próximo reinicio + real del NAS (ver la nota de la causa c). +- [ ] Limpiar las etiquetas de imagen viejas de `tmux-host` + (`supervised`, `supervised-v2`, `local`, `test-fix`, etc.) para no + confundir cuál es la vigente. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 57f2d76..d15c744 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -114,6 +114,7 @@ nav: - Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md - Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md - Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md + - "Cadena de fallos tmux-host": playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md - "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md - Aprendizajes: - Notas sueltas: aprendizajes/notas-sueltas.md