Reanuda tu agente sin repetir efectos ya aplicados
Un agente que ejecuta una tarea larga acaba tocando el mundo real: escribe ficheros, llama a APIs, abre pull requests, manda correos. Y las tareas largas se cortan. Se cae la red a mitad de un POST, el contenedor recibe un SIGTERM en pleno despliegue, el disco se llena entre dos escrituras. Cuando relanzas el proceso, la pregunta no es si el agente sigue siendo listo: es si sabe qué dejó aplicado antes de morir.
El fallo típico no es que la tarea se pierda. Es que se aplique dos veces. Un fichero a medio escribir que otro proceso ya ha leído, una factura emitida por duplicado, dos issues idénticas en el repositorio. Esta es la referencia técnica para diseñar la reanudación antes de que ocurra el incidente, no después.
Qué es una reanudación idempotente
Un paso agente es idempotente cuando ejecutarlo varias veces con la misma clave produce un único efecto lógico observable en el sistema de destino. La reanudación idempotente es el diseño que permite matar el proceso en cualquier instante, relanzarlo y obtener el mismo resultado final que si nunca se hubiera interrumpido, sin efectos duplicados ni estados a medias.
La documentación de Temporal sobre definición de actividades lo formula igual y añade el matiz operativo que importa: los reintentos automáticos dan semántica de ejecución at-least-once, no exactly-once. El runtime garantiza que el paso se ejecuta; tú garantizas que ejecutarlo dos veces no cobra dos veces.
Y cuidado con el atajo mental: un checkpoint no es idempotencia. Guardar el estado del grafo te dice dónde estabas, no qué efectos externos llegaron a aplicarse. Son dos problemas distintos y necesitas resolver los dos.
Separa cada paso con efecto en preparar, publicar y confirmar
La regla estructural es que ningún paso con efecto externo sea una sola operación indivisible en tu código. Divídelo en tres fases con una frontera observable entre ellas:
- Preparar: construyes el resultado en un sitio que nadie lee (un fichero temporal, una fila con estado
pending, un payload serializado). Es la fase cara y la que puedes repetir sin daño. - Publicar: haces visible el resultado con una única operación que el sistema de destino trata como atómica y deduplicable. Un
rename, un INSERT con índice único, un POST con clave de idempotencia. - Confirmar: registras en tu estado persistido que la publicación ocurrió, junto con el identificador que devolvió el destino.
Esta separación es el mismo principio de separación de responsabilidades aplicado al eje temporal en lugar de al eje de módulos: separas lo que se puede repetir de lo que no se puede repetir. Dentro de este diseño de tres fases, el hueco crítico está entre publicar y confirmar. Ahí el efecto ya existe y tu estado aún no lo sabe, y ahí es donde la clave de idempotencia te salva. No es el único punto frágil del esquema (la propia escritura de la confirmación puede quedarse a medias, y eso lo tratamos más abajo), pero sí el que obliga a diseñar deduplicación en el destino.
La tabla de decisión al arrancar
Al reanudar, el agente no debe preguntarle al LLM qué hacer. Debe leer el estado persistido de cada paso y aplicar esta tabla. Copia estos estados tal cual: son el núcleo del diseño.
| Estado en disco | Qué llegó a pasar | Acción al reanudar |
|---|---|---|
| sin registro | El paso no empezó, o murió antes de escribir nada | Ejecutar el paso completo |
prepared | Hay un temporal escrito, nadie lo ha visto | Borrar el temporal y repetir desde preparar |
submitted | La llamada salió, no hay respuesta. El efecto puede existir o no | Reintentar con la misma clave y los mismos parámetros |
published | El efecto existe una vez, falta confirmar | Escribir la confirmación y pasar al siguiente paso |
done | Paso cerrado | Saltar sin tocar nada |
failed_permanent | Error de validación o 4xx no reintentables | No reintentar. Escalar o abortar la tarea |
El estado submitted es el que la mayoría de implementaciones se salta, y es justo el que cubre la ventana de incertidumbre de red. Sin él, tu agente solo distingue entre no hecho y hecho, y la reanudación se convierte en una apuesta.
El registro que persistes por paso tiene que ser suficiente para decidir sin volver a razonar. Este es el formato mínimo:
Una línea por paso en un fichero JSONL o una fila por paso en SQLite. La clave es que contenga la respuesta del destino, no solo el estado.
{
"run_id": "rpt-2026-09-17-a1b2",
"step_id": "publish_invoice",
"idem_key": "rpt-2026-09-17-a1b2:publish_invoice:9f3c1d",
"status": "published",
"params_hash": "9f3c1d",
"remote_id": "in_1QxYz",
"attempts": 2,
"updated_at": "2026-09-17T11:42:08Z"
}Este registro no es memoria del agente ni contexto conversacional. Es el diario de efectos, y vive en disco o en base de datos aunque el proceso muera. Confundir las dos cosas es un error común cuando empiezas a dar memoria persistente a un agente: la memoria sirve para recordar, el diario de efectos sirve para decidir.
La clave de idempotencia y por qué los parámetros importan
La clave tiene que ser determinista. Si la generas con uuid4() en cada arranque, el reintento no deduplica nada: para el destino son dos operaciones distintas. Derivarla del identificador de ejecución más el identificador del paso más un hash de los parámetros te da una clave estable entre reinicios y única entre tareas. Temporal recomienda exactamente ese patrón, combinando Workflow Run ID y Activity ID.
Los parámetros forman parte del contrato, no son decoración. La referencia de peticiones idempotentes de Stripe compara los parámetros entrantes con los de la petición original y devuelve un error si no coinciden, precisamente para que un reintento mal construido no acabe ejecutando otra operación bajo la misma clave. Si tu agente regenera el payload con un LLM antes de reintentar, el payload cambia, el hash cambia y la protección desaparece. Por eso el payload se congela en la fase de preparar y se reutiliza tal cual.
Esta función construye la clave a partir de datos que sobreviven al reinicio, con serialización canónica para que el hash no dependa del orden de las claves:
import hashlib, json
def idem_key(run_id: str, step_id: str, params: dict) -> str:
canon = json.dumps(params, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canon.encode()).hexdigest()[:6]
return f"{run_id}:{step_id}:{digest}"
Regla de decisión: si el destino acepta clave de idempotencia, úsala y guarda el remote_id que devuelve. Si no la acepta, necesitas una comprobación previa por identificador de negocio (buscar la issue por título, el fichero por ruta, la fila por clave única) antes de publicar. Si tampoco tienes eso, el efecto no es seguro de reintentar y debe pedir aprobación humana.
Temporal más rename: publicar sin lecturas parciales
Para ficheros, publicar significa un solo syscall. El manual de rename(2) es explícito: si el destino ya existe, se reemplaza de forma atómica, de modo que no hay ningún instante en el que otro proceso que intente acceder a esa ruta la encuentre ausente. Escribir directamente sobre el fichero final rompe esa garantía y expone contenido truncado a cualquiera que lea mientras escribes.
En Python, os.replace es la operación atómica; os.rename no reemplaza en Windows. El fsync del fichero antes del reemplazo es lo que convierte atómico en duradero frente a un corte de energía:
import os, tempfile
def write_atomic(path: str, data: str) -> None:
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=d) # mismo directorio: mismo filesystem
try:
with os.fdopen(fd, "w") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
os.unlink(tmp)
raise
El temporal va en el mismo directorio a propósito. Si lo creas en /tmp y el destino está en otro sistema de ficheros, rename falla con EXDEV y tu librería acaba haciendo una copia byte a byte, que no es atómica.
El mismo patrón aplica al propio diario de efectos: escribe el registro nuevo en temporal y reemplázalo, o usa una base de datos transaccional. Un diario corrupto por una escritura a medias es peor que no tener diario, porque el agente reanuda con información falsa.
Cuándo esto no aplica
No todos los pasos merecen esta maquinaria. Si el paso es una lectura pura, un cálculo local o una llamada a un LLM sin efectos externos, reejecutarlo solo cuesta tiempo y tokens: ahí el problema es de presupuesto, no de corrección, y se ataca con caché de prompts y recorte de contexto. El umbral práctico: aplica preparar/publicar/confirmar solo a los pasos que un humano tendría que deshacer a mano si se duplicasen.
Tampoco sustituye a un control de autonomía. Un efecto irreversible y caro sigue necesitando confirmación antes de ejecutarse, y ahí la decisión es cuánto margen le das al agente según el riesgo del cambio, no cómo lo reintentas.
Dónde se rompe el esquema
Los casos borde que conviene tener escritos antes del primer incidente:
- Las claves caducan. Stripe elimina las claves de idempotencia cuando tienen al menos 24 horas y genera una petición nueva si reutilizas una ya purgada, según su guía de manejo avanzado de errores. Si tu tarea puede quedarse parada un fin de semana esperando aprobación, la clave ya no te protege al reanudar: necesitas comprobación por identificador de negocio.
- Dos procesos reanudando la misma tarea. El identificador de hilo es un puntero al estado, no un cerrojo. En LangGraph, el
thread_ides el cursor que decide qué checkpoint se recarga, tal como describe la documentación de interrupciones de LangChain, y nada impide que dos workers lo reanuden a la vez. Toma un lease con expiración antes de reanudar. - Los runtimes de replay reejecutan la unidad entera. Al reanudar, se vuelve a ejecutar el nodo o la actividad desde su inicio, no desde la línea donde murió el proceso. Si metes tres llamadas con efecto dentro del mismo nodo, las tres se repiten. Un efecto por unidad reanudable.
- El LLM no garantiza reproducibilidad exacta entre llamadas. Ni con
temperature=0ni con el mismo prompt tienes la promesa de una salida idéntica, así que reanudar puede no regenerar lo que había antes del corte. Persiste el resultado, no la intención de recalcularlo, o tendrás dos versiones distintas del mismo artefacto. Esto agrava el problema de los agentes que se pierden en tareas largas. - El apagado importa. Si el proceso ignora SIGTERM, el orquestador lo mata a mitad de la fase de publicar con más frecuencia de la que crees. Un apagado ordenado que deja terminar la operación en curso reduce la ventana peligrosa, aunque no la elimina.
- Sistemas de ficheros de red. La garantía atómica de
renamees de POSIX sobre un mismo sistema de ficheros. Sobre NFS u otros montajes remotos, el comportamiento depende de la implementación y conviene no darlo por hecho.
Anthropic insiste en su guía de construcción de agentes efectivos en diseñar la interfaz entre el agente y sus herramientas con el mismo cuidado que la interfaz humana. La reanudación es parte de esa interfaz: una herramienta que no sabe decir si su efecto ya se aplicó obliga al agente a adivinar, y adivinar es exactamente lo que no quieres que haga con un efecto irreversible delante.