Saltar al contenido principal

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:

  • resolveTurnInput reanuda 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:

  1. El nodo require_approval la crea, emite el aviso opcional a quien escribe y termina su rama (arista a END). El turno acaba con normalidad y el bot sigue disponible.
  2. 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.
  3. ApprovalsService vuelve a invocar el grafo con enterAt = el destino de la rama "aprobado" o "rechazado". El router de START honra ese campo, que BotsService limpia en cada turno normal.
  4. 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 en messages para que se vea en la bandeja.
  5. 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_at lo 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.