Un clic en Regenerar y 107 mensajes desaparecieron

La última respuesta no era del todo correcta, así que haces clic en Regenerar. La interfaz se refresca y todo parece ir bien — hasta que subes y ves que la conversación que tuviste hace cuatro horas ha desaparecido, junto con todo lo que hubo entre aquel momento y ahora. No has borrado nada. Nunca hiciste clic en nada que dijera “clear”.
Este caso se reportó de verdad: un solo regenerate convirtió una sesión de 430 mensajes en 323. 107 mensajes activos se archivaron en silencio, incluidos todos los posteriores a un turn de cuatro horas antes.
430 se convirtieron en 323
El bug está en la pregunta “¿a qué fila está rebobinando este regenerate?”.
En Desktop, regenerate y edit calculan primero un plan de truncado: la fila que se va a rehacer y todo lo posterior contado como historial. El servidor validaba ese plan al enviarlo, pero solo rechazaba tres casos:
- anclas obsoletas (error 4018);
- deriva de ordinal (4030);
- cortes de todo el transcript (4028).
Lo que se colaba entre los tres: un ancla de row-id válida que apuntaba a una fila muy antigua. Y la ruta de regenerate del cliente confiaba en su propio parentId sin preguntar. Juntas, un regenerate pulsado sobre el mensaje final podía reproducir legítimamente el turn en el ordinal 31 y archivar los cien y pico mensajes posteriores.
Reproducido en main, una sesión de 430 mensajes quedó reducida a 310 en el ordinal 31 sin nada que lo impidiera. Conviene decirlo con claridad: la causa raíz del lado del cliente no se reprodujo y no se identificó ningún commit culpable concreto. En otras palabras, más de una ruta puede desencadenar este tipo de fallo — por eso el arreglo se puso en la salida y no en una entrada concreta.
Ahora pregunta primero
El enfoque no es “adivinar mejor qué turn querías”. Es mostrarte la consecuencia antes de que la acción se ejecute.
- Regenerate (
planConfirmedReload) y edit (planConfirmedEdit) ahora comparten una única comprobación de corte profundo (confirmIfDeepCut); - Cualquier rewind que fuera a descartar un turn de usuario visible posterior abre el diálogo de confirmación destructiva ya existente (
deepCutConfirmRequest, reutilizando el texto de restore, así que no se añadieron nuevas cadenas de interfaz); - Lo que no se interrumpe también queda definido explícitamente: un regenerate de la cola, un edit del último turn y un edit de un turn fallido no preguntan;
- Si rechazas, no se envía nada. Restore conserva su propio diálogo aparte.
Un diálogo puede quedarse abierto mucho tiempo — te fuiste a por un café —, así que los cambios durante esa ventana se gestionan:
- Regenerate descarta el plan si la sesión se puso busy o su transcript cambió;
- Edit vuelve a apuntar al transcript actual con
revalidateEditPlan, de modo que una respuesta en streaming que crece queda contemplada; se detiene con un aviso si el turn editado se movió o llegó un nuevo turn de usuario posterior; - Ambos descartan la acción si cambiaste de sesión.
El servidor también cuenta
El aviso del cliente es una capa. El servidor ahora cuenta de verdad lo que un corte descartaría: _archived_user_turns mide el corte en filas de usuario durables, y cada detalle se inclina por lo conservador:
- Un carrier reparado cuenta cada fila de su run user;user fusionado, leída de las filas físicas;
- Los marcadores de display absorbidos no cuentan;
- Los ids desconocidos y el propio turn de un carrier sin sello sí cuentan — fail closed antes que contar de menos;
- En un corte de carrier durable, las filas anteriores al objetivo sobreviven.
Si el cliente y el servidor cuentan los turns de forma distinta, el servidor responde 4033. Cuando el cliente ve 4033:
- Revierte de inmediato el estado optimista, ya que el envío rechazado no escribió nada;
- Después te pregunta. Si aceptas, regenerate o edit se vuelven a ejecutar desde el principio mediante los privados
runReload/runEdit, releyendo la sesión y volviéndola a comprobar; - El corte profundo forzado cubre solo el transcript por el que respondiste. Cualquier cambio durante ese proceso vuelve a preguntar.
El reintento de target obsoleto (4018) que ya tenía edit también se ha ajustado: solo arrastra la confirmación de corte profundo cuando el transcript refrescado tiene el mismo número de turns de usuario posteriores.
Esta versión solo avisa
Una cosa hay que dejarla clara: esta versión todavía no bloquea los cortes profundos. Los registra.
Cuando el servidor ve un corte no confirmado de más de un turn de usuario, registra ALLOWED (warn-only release) y aun así lo ejecuta. El interruptor de aplicación forzosa es DEEP_TRUNCATE_ENFORCED; en cuanto la próxima versión lo active, el mismo corte se rechazará con 4033 y el cliente tomará el camino de “revertir, preguntar, re-ejecutar” descrito arriba.
La secuencia es deliberada: una versión de datos reales sobre con qué frecuencia falla esto, y después la barrera dura — en lugar de enviar la aplicación forzosa de inmediato y bloquear rewinds profundos legítimos.
Estado y actualización
Este arreglo (PR #133748, que cierra la incidencia #133716, etiquetada P0) se fusionó en main el 2026-10-11. El tag actual, v0.21.6 del 8 de octubre, no lo contiene; la próxima versión sí.
Tras actualizar notarás dos cosas: muchos menos cortes accidentales y alguna confirmación extra de vez en cuando. Ese diálogo es justamente el objetivo del cambio — cuando aparece, significa que estabas a punto de perder una conversación que aún no habías leído.
Para terminar
Regenerate es el botón que la gente trata como “deshacer el último paso”, cuando en realidad significa “rehacer desde una fila concreta, y todo lo posterior pasa a ser historial”. Confundir ambas cosas permite que un solo clic perdido se trague toda una tanda de trabajo. La nueva regla es contundente: si se va a perder conversación posterior, pregunta primero. El coste de proteger una sesión es un clic; el coste de perder 107 mensajes es que nunca llegas a saber qué decían.
Lecturas relacionadas: reanudar y continuar una sesión cubre cómo retomar una conversación, la guía de límites de sesión explica qué cuenta como límite de usuario, y el aislamiento del embed de Desktop trata de lo que ocurre dentro de la UI y de lo que se te cuenta al respecto.