0009 — La aprobación humana vive fuera del grafo
Fecha: 2026-09-20 · Estado: aceptada
Contexto
Un flujo tiene que poder frenar antes de una acción delicada —cancelar una cita, emitir una factura— y esperar a que una persona del equipo la apruebe.
LangGraph ya tiene una pausa: interrupt(), que es justo lo que usa el nodo "Esperar respuesta".
La tentación era reutilizarla.
El usuario decidió (2026-09-19) que mientras se espera la aprobación el bot sigue atendiendo la conversación, y que si nadie decide en el plazo cuenta como rechazo.
Problema con el interrupt
Con el bot atendiendo, la pausa de la aprobación no sobrevive:
resolveTurnInputreanuda cualquier interrupt pendiente con el texto de quien escribe: el paciente acabaría "aprobando" sin saberlo.- Y aunque se filtrara por tipo, una entrada normal a un hilo pausado descarta las tareas pendientes (es lo que hace que el flujo vuelva a correr desde START). El siguiente mensaje del paciente borraría la aprobación.
Un interrupt solo funcionaría bloqueando la conversación, que es justo lo que se descartó.
Decisión
La aprobación es una fila en bot_approvals, no un estado del grafo:
- El nodo
require_approvalla crea, emite el aviso opcional a quien escribe y termina su rama (arista aEND). El turno acaba con normalidad y el bot sigue disponible. - Quien decide lo hace desde el panel con su sesión, por el RPC
bot_decide_approval, que comprueba el permiso que pidió el nodo y el ámbito de esa conversación. El backend no puede autorizar eso: va con service-role y ahíauth.uid()es NULL. ApprovalsServicevuelve a invocar el grafo conenterAt= el destino de la rama "aprobado" o "rechazado". El router de START honra ese campo, queBotsServicelimpia en cada turno normal.- Lo que el bot diga a partir de ahí no vuelve por ningún webhook: sale por
ConversationOutboundService(mismo camino que el nodo "enviar mensaje") y se guarda enmessagespara que se vea en la bandeja. - Un barrido cada minuto vence las de plazo pasado (
bot_expire_approvals) y recoge las decididas que se quedaron sin continuar (un reinicio, un fallo de red).continued_atlo hace idempotente.
Consecuencias
- La conversación nunca se queda bloqueada por una aprobación, ni se pierde por un mensaje nuevo.
- El turno que retoma se mide como cualquier otro, con
entry = 'approval'. - Queda un caso que hay que tener presente: si se activa una versión del flujo mientras hay aprobaciones pendientes y el nodo desaparece, al decidirse no habrá rama a la que volver; la fila se cierra sin continuar y queda registrada.
- Si el plazo vence, el flujo sigue por "rechazado": la acción delicada nunca se ejecuta sola.