Ir al contenido principal
Tu agente de contratos manda alertas dos veces. Por qué.

Tu agente de contratos manda alertas dos veces. Por qué.

AI Integration
6 min readPor Daily Miranda Pardo

El CFO abre el correo. Tres emails de renovación de contrato. Mismo proveedor. Mismo importe. Mismo asunto. Enviados a las 03:00, las 03:01 y las 03:02 de la madrugada.

El agente funciona. El problema es que funcionó tres veces.

Este bug no lo produce un prompt mal escrito ni un modelo equivocado. Lo produce una arquitectura que no contempla que el scheduler puede ejecutarse más de una vez, que dos workers pueden leer el mismo estado antes de que ninguno lo actualice, o que la lógica de "cuándo enviar" devuelve true durante días enteros sin ningún freno.

Si estás construyendo un agente de monitoreo —contratos, suscripciones, renovaciones, alertas de vencimiento— este artículo te ahorra el momento de vergüenza.

Los agentes de monitoreo no son APIs REST

Un endpoint HTTP es naturalmente idempotente: llámalo dos veces con los mismos datos y el sistema queda en el mismo estado. No hay efectos secundarios que se acumulen.

Un agente de monitoreo es exactamente lo contrario. Cada ejecución tiene efectos secundarios reales: envía un email, escribe en base de datos, llama a una API externa. Si lo ejecutas dos veces, esos efectos ocurren dos veces. El sistema no queda igual.

La diferencia parece obvia cuando se escribe así. El problema es que la mayoría de implementaciones de agentes de monitoreo están diseñadas como si fueran endpoints REST: sin estado propio, sin deduplicación, confiando en que "el scheduler no lo llamará dos veces". Y el scheduler siempre llama dos veces, tarde o temprano.

Los tres escenarios donde llega duplicada

1. El scheduler que reinicia en mal momento

Cuando usas n8n, un cron de Vercel, o cualquier sistema de scheduling distribuido, el job puede ejecutarse más de una vez. Un nodo que se reinicia mientras el job está a mitad de ejecución lo relanzará desde el principio. Un deploy que coincide con la ventana de ejecución puede arrancar el job en dos instancias simultáneas.

Resultado: dos ejecuciones paralelas encuentran el mismo contrato con alerted: false, las dos envían el email, las dos actualizan el estado a alerted: true. Misión cumplida dos veces.

2. La lógica de fecha que evalúa true durante días

El error más común en agentes de renovación:

// Esto parece correcto. No lo es.
const daysUntilRenewal = differenceInDays(renewalDate, new Date());

if (daysUntilRenewal <= 30) {
  await sendAlert(contract, '30d');
}

Esta condición es true el día 30, el 29, el 28, el 27... hasta el día del vencimiento. Si el agente corre cada día —como suele hacer un cron— manda una alerta diaria durante 30 días seguidos.

3. La race condition en base de datos

Dos workers leen el mismo registro antes de que ninguno marque la alerta como enviada:

Worker A: SELECT * FROM contracts WHERE alerted_30d = false  → [contrato-123]
Worker B: SELECT * FROM contracts WHERE alerted_30d = false  → [contrato-123]
Worker A: sendEmail(contrato-123)
Worker B: sendEmail(contrato-123)  ← duplicado
Worker A: UPDATE contracts SET alerted_30d = true WHERE id = contrato-123
Worker B: UPDATE contracts SET alerted_30d = true WHERE id = contrato-123

Ambos llegan a la misma conclusión porque leen antes de escribir. Y en sistemas distribuidos, "leer antes de escribir" es siempre una race condition esperando ocurrir.

La solución: dedup key + write-before-act

La corrección requiere dos cambios de arquitectura que resuelven los tres escenarios simultáneamente.

Primero: una tabla de alertas con constraint único

CREATE TABLE contract_alerts (
  id          UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  contract_id UUID NOT NULL REFERENCES contracts(id),
  alert_type  TEXT NOT NULL CHECK (alert_type IN ('60d', '30d', '15d')),
  sent_at     TIMESTAMPTZ DEFAULT now(),
  UNIQUE (contract_id, alert_type)
);

El constraint UNIQUE (contract_id, alert_type) garantiza que nunca existan dos filas para la misma combinación. La base de datos rechaza el duplicado antes de que ningún código lo gestione.

Segundo: intentar insertar antes de actuar

async function sendRenewalAlert(
  contractId: string,
  alertType: '60d' | '30d' | '15d',
  contract: Contract
): Promise<void> {
  const { error } = await supabase
    .from('contract_alerts')
    .insert({ contract_id: contractId, alert_type: alertType });

  if (error?.code === '23505') {
    // Violación de unique constraint: esta alerta ya fue enviada
    // No hay nada que hacer
    return;
  }

  if (error) throw error;

  // Solo llegamos aquí si la inserción fue exitosa.
  // Somos los primeros en procesar esta alerta.
  await sendRenewalEmail(contract, alertType);
}

El código PostgreSQL 23505 es la violación de unique constraint. Si ocurre, sabemos que otro proceso —u otra ejecución del mismo proceso— ya gestionó esta alerta. No hacemos nada.

Este patrón resuelve los tres escenarios:

  • Scheduler que reinicia: la segunda ejecución falla al insertar, no envía nada
  • Condición que evalúa durante días: el segundo día falla al insertar, no envía nada
  • Race condition: el segundo worker falla al insertar (la BD serializa el INSERT), no envía nada

El orden importa: la regla que la mayoría invierte

Hay un cuarto error que aparece incluso cuando se implementa el dedup key correctamente: enviar el email antes de escribir en la base de datos.

// ❌ Orden incorrecto
// Si el email falla a mitad, la BD queda sin actualizar.
// El agente lo reintentará y enviará otro.
await sendRenewalEmail(contract);
await markAlertAsSent(contractId, alertType);

// ✅ Orden correcto
// La BD actúa como "reserva de slot" atómica.
// Si la inserción falla → no actuamos.
// Si el email falla tras insertar → tenemos registro, no duplicamos.
const inserted = await reserveAlertSlot(contractId, alertType);
if (inserted) {
  await sendRenewalEmail(contract);
}

La base de datos es tu fuente de verdad. Escribe primero. Actúa después.

Si la acción falla después de escribir, el sistema sabe que "ya lo intentó" y no reintentará. Puedes añadir lógica de reintento controlada sobre ese registro, pero nunca volverás a un estado donde "parece que no ha ocurrido nada" cuando sí ha ocurrido algo.

Lo que esto cambia en producción

Un agente de monitoreo construido con estas garantías puede ejecutarse con at-most-once semantics para los efectos que importan: el email llega una sola vez, aunque el job se ejecute cincuenta.

Esto no es un detalle de implementación menor. Es la diferencia entre un sistema que el CFO confía y uno que produce vergüenza en el peor momento —generalmente cuando hay un vencimiento importante encima y el proveedor recibe tres emails de la misma empresa en dos minutos.

El agente de monitoreo que describimos en el artículo sobre compromisos económicos sin supervisión —alertas a 60, 30 y 15 días antes del vencimiento— solo funciona como se promete cuando la arquitectura subyacente tiene estas garantías. De lo contrario, el agente existe, manda emails, y genera exactamente el caos que intentaba evitar.

Si estás construyendo este tipo de sistema y ya tienes un agente en producción con comportamiento impredecible, el servicio de integración IA para empresas empieza exactamente ahí: auditar lo que ya existe antes de añadir más funcionalidad encima.

¿Tu agente tiene comportamiento impredecible en producción? Cuéntame qué ocurre →

Compartir artículo

LinkedInXWhatsApp

¿Procesos repetitivos en tu empresa?

Descarga gratis el Mapa de Automatización IA — los 5 procesos que más tiempo roban y cómo resolverlos.

Sin spam. Solo el PDF. Puedes darte de baja cuando quieras.

Escrito por Daily Miranda Pardo

Ayudo a empresas a automatizar procesos, crear agentes IA y conectar sistemas inteligentes.