Un agente no debería reanudarse cargando la conversación y pidiendo al modelo que continúe. La conversación registra lo que se dijo. No demuestra que una tool terminó, que un sistema externo aplicó el efecto ni que la respuesta sobrevivió al proceso que hizo la llamada.

Después de un crash, carga el último checkpoint confirmado, recupera la intención y el resultado de cada paso, y trata la primera llamada sin recibo como una decisión de recuperación. Si el proveedor acepta una clave de idempotencia, consulta o repite la operación con la misma clave. Si no existe una forma fiable de saber qué ocurrió, marca el resultado como desconocido y detente antes de producir otro cambio.

Este artículo es un spoke de ejecución durable para agentes de IA. El owner explica cuándo encajan una cola, un checkpoint o un workflow. Aquí el foco está en el fallo entre un efecto externo y el registro local, con una fixture de Node.js que puedes interrumpir y reiniciar.

Diagrama muestra un agente que reanuda desde un checkpoint y comprueba un efecto desconocido antes de repetir una tool call.

Conclusiones clave

  • Guarda la decisión del modelo antes de ejecutar tools.
  • Guarda el resultado de cada tool antes de iniciar el siguiente paso.
  • Genera una clave estable por efecto y reutilízala al repetir.
  • Después de un crash, comprueba el sistema externo antes de decidir si debes ejecutar de nuevo la operación.
  • Si el resultado sigue siendo desconocido y repetir puede causar daño, pausa para una reconciliación humana u operativa.

Por qué el historial de la conversación no recupera la ejecución

Imagina que un agente recibe una solicitud, pide ejecutar create_ticket y el proceso muere justo después de que el sistema de tickets acepta la operación. El historial puede contener la llamada a la tool, pero puede no contener el ID del ticket ni una confirmación que sobreviva al proceso.

Si el worker reiniciado envía toda la conversación al modelo, el modelo puede elegir otra acción. Aunque elija la misma tool, una nueva petición puede crear un segundo ticket. El problema no es solo perder contexto. Es tratar una intención registrada como un efecto confirmado.

La guía de AWS sobre ejecución durable para agentes describe el mecanismo como step, checkpoint y replay: el resultado de cada paso vive fuera del proceso y la recuperación continúa desde esos registros. La documentación de tareas de Cloudflare Agents añade un límite importante: un paso interrumpido puede ejecutarse otra vez, así que un efecto externo necesita una clave estable o una lectura duradera del progreso.

Por eso un trace no es un recibo. Un trace ayuda a investigar el camino. Un recibo, el ID devuelto por el proveedor o una consulta al sistema que cambió ayuda a decidir qué puede ocurrir después.

Qué estado debe sobrevivir al proceso

El registro de recuperación no necesita guardar toda la conversación en un solo documento. Necesita suficiente información para que el runtime elija el siguiente paso sin depender de la memoria del worker:

Registro Propósito
runId y versión del flujo Encuentra la ejecución y evita continuar con una definición incompatible
turno y llamadas del modelo Reproduce la intención elegida sin pedir al modelo que elija otra vez
ID de llamada y argumentos Identifica la operación lógica que puede repetirse
clave de idempotencia Conecta los intentos de la misma operación en el sistema externo
resultado o recibo Permite saltar un paso completado
estado unknown Evita tratar un fallo de comunicación como prueba de que no hubo efecto
lease o fencing token Impide que dos workers avancen la misma ejecución a la vez

Escribe el registro en un almacenamiento que sobreviva a la muerte del proceso. Un archivo atómico basta para una fixture local. En producción, usa una base de datos, un volumen durable o el almacenamiento de un motor de workflows, con control de concurrencia y una política de retención.

La documentación de checkpoints de Microsoft Agent Framework describe snapshots que incluyen el estado de los executors, mensajes pendientes, solicitudes y respuestas. La misma página exige que el workflow reconstruido conserve su topología y las identidades de los executors. La idea se aplica fuera de ese framework: el checkpoint debe seguir siendo compatible con el código que lo interpreta después de un despliegue.

Qué hacer en cada ventana de crash

El punto del fallo cambia la recuperación. No uses un único camino de retry para todas las situaciones.

Punto del fallo Lo que sabes Siguiente acción
Antes de enviar la tool El efecto no fue solicitado Envíala con la clave de operación persistida
Después de enviar, sin respuesta El efecto puede haber ocurrido Busca la clave o lee el estado del destino
Después del efecto, antes del checkpoint El efecto existe, pero el registro local no lo sabe Búscalo o repite con la misma clave, nunca con una nueva
Después de guardar el recibo El efecto y el paso están confirmados Reutiliza el resultado y avanza
Sin consulta fiable y con riesgo de duplicación El estado sigue siendo ambiguo Marca unknown, pausa y reconcilia

Un retry es otro intento de una operación. Reanudar es continuar una ejecución desde el último estado confirmado. Un retry puede formar parte de la reanudación, pero solo después de que el runtime clasifique el efecto.

Cómo implementar un journal pequeño en Node.js

El ejemplo usa dos archivos para hacer visibles las fronteras. agent-run.json es el journal del runtime. effects.json representa al proveedor externo, que reconoce la misma clave de operación. El modelo se sustituye por un plan fijo para que la prueba no dependa de red, tokens ni una decisión no determinista.

Guárdalo como agent-recovery.mjs:

import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";

const RUN_FILE = "agent-run.json";
const EFFECTS_FILE = "effects.json";
const runId = "ticket-77";

const plan = [
  [{ id: "c1", tool: "create_ticket", args: { title: "Login falla" } }],
  [{ id: "c2", tool: "send_email", args: { to: "dana@example.com" } }],
  [],
];

function load(file, fallback) {
  return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : fallback;
}

function saveAtomically(file, value) {
  const temporary = `${file}.tmp`;
  writeFileSync(temporary, JSON.stringify(value, null, 2));
  renameSync(temporary, file);
}

function model(turns) {
  return plan[turns] ?? [];
}

function providerLookup(key) {
  const effects = load(EFFECTS_FILE, {});
  return effects[key] ?? null;
}

function providerExecute(tool, args, key) {
  const effects = load(EFFECTS_FILE, {});
  if (effects[key]) return effects[key];

  const result = {
    providerId: `${tool}-1`,
    tool,
    args,
    confirmedAt: "fixture",
  };

  // Un proveedor real debe guardar esta clave junto con el efecto.
  effects[key] = result;
  saveAtomically(EFFECTS_FILE, effects);
  return result;
}

async function run() {
  const state = load(RUN_FILE, { runId, turns: [] });

  for (let turnIndex = 0; ; turnIndex += 1) {
    if (!state.turns[turnIndex]) {
      state.turns[turnIndex] = {
        calls: model(turnIndex),
        results: {},
      };
      // Persiste la decisión del modelo antes de cualquier efecto.
      saveAtomically(RUN_FILE, state);
    }

    const turn = state.turns[turnIndex];
    for (const call of turn.calls) {
      if (turn.results[call.id]) {
        console.log(`skip ${call.tool}: resultado ya guardado`);
        continue;
      }

      const key = `${runId}:${turnIndex}:${call.id}`;
      const known = providerLookup(key);
      const result = known ?? providerExecute(call.tool, call.args, key);

      if (process.env.CRASH_AFTER_EFFECT === "1" && !known) {
        console.log(`crash después del efecto: ${call.tool}`);
        process.kill(process.pid, "SIGKILL");
      }

      turn.results[call.id] = result;
      saveAtomically(RUN_FILE, state);
      console.log(`${call.tool}: ${known ? "recibo encontrado" : "efecto aplicado"}`);
    }

    if (turn.calls.length === 0) {
      console.log("ejecución completada");
      return;
    }
  }
}

run().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

El ejemplo tiene dos propiedades importantes. Primero, guarda la salida del modelo antes de la primera tool. Un reinicio no llama al modelo para redescubrir el plan. Segundo, providerLookup comprueba el sistema externo antes de providerExecute. Cuando el proceso muere después de providerExecute y antes de guardar agent-run.json, la siguiente ejecución encuentra el recibo en effects.json y lo registra en el journal sin crear un segundo efecto.

effects.json es una simulación. Representa una garantía que tu proveedor debe ofrecer, como idempotencia por clave, una consulta de estado o una tabla de operaciones. Un archivo local controlado por el mismo proceso no demuestra atomicidad en un sistema distribuido.

Cómo provocar el crash y verificar la reanudación

Ejecuta el primer proceso con el fallo inyectado:

rm -f agent-run.json effects.json
CRASH_AFTER_EFFECT=1 node agent-recovery.mjs || true

La primera ejecución escribe el efecto del ticket y se detiene antes de escribir el recibo en el journal. Reinicia la misma ejecución:

node agent-recovery.mjs
node agent-recovery.mjs

En la segunda ejecución, create_ticket debe mostrar recibo encontrado, y send_email debe aplicarse una vez. En la tercera, ambas llamadas ya tienen resultados en el journal. La salida final debe contener ejecución completada, y effects.json debe contener una entrada por cada clave.

Esta prueba demuestra tres decisiones del harness: el plan no se recalculó después del crash, el efecto incierto se reconcilió con una clave estable y el resultado recuperado se guardó antes de continuar. No demuestra que un proveedor real trate la clave de forma idempotente, que dos workers no puedan ejecutarse juntos ni que una transacción externa sea atómica.

Cuándo necesitas un lease y una versión del flujo

El ejemplo supone un proceso por ejecución. En producción, un despliegue puede iniciar un worker nuevo mientras el anterior sigue bloqueado en una llamada de red. Una clave de idempotencia reduce el riesgo, pero no sustituye el control de quién posee la ejecución.

Usa un lease con expiración y un fencing token cuando más de un worker pueda competir por la misma ejecución. El worker debe renovar el lease, validar el token antes de avanzar el checkpoint y perder la capacidad de escribir cuando el lease expire. La actualización también debe comprobar la versión esperada del estado para rechazar una escritura retrasada.

Fija la versión del flujo por ejecución. Una ejecución iniciada con otra descripción de tool, otro formato de argumentos o otra regla de transición puede no ser compatible con un journal antiguo. El recorrido de Microsoft Foundry para un agente resistente a crashes muestra el mismo principio: el servidor guarda la respuesta, crea checkpoints por etapa y omite la etapa checkpointed después del reinicio. En ese ejemplo, la opción de resiliencia debe activarse explícitamente.

Uso RemoteCode como herramienta propia cuando necesito continuidad entre sesiones de Codex y Claude Code. Puede reducir el contexto repetido, pero no sustituye el journal, el lease, la idempotencia ni la consulta del efecto externo.

Cuándo detenerse en vez de repetir

Detente cuando no puedas responder una de estas preguntas:

  • ¿Qué operación lógica se está recuperando?
  • ¿El sistema externo ofrece una clave de idempotencia o una consulta fiable?
  • ¿Puedes observar el efecto sin producir otro cambio?
  • ¿El worker antiguo perdió el lease y su capacidad de escribir?
  • ¿Siguen vigentes la aprobación, el presupuesto y la versión del flujo?

Si la respuesta es no y la operación puede enviar un mensaje, crear un registro, cambiar un permiso o mover dinero, unknown es mejor que failed. failed sugiere que no ocurrió nada. unknown mantiene la obligación de reconciliar antes de volver a intentarlo.

Esta regla complementa cómo verificar una tool call antes de reintentarla. Ese artículo se centra en comprobar una llamada. Aquí la comprobación forma parte del protocolo completo de reinicio, que empieza en el checkpoint y termina con un resultado recuperado o una pausa explícita. Para una tarea que depende de un broker y redelivery, consulta cómo probar un worker de cola sin duplicar efectos.

Un workflow como Temporal, Step Functions, LangGraph u otro runtime durable puede asumir parte del journal y del replay. Eso reduce el código de infraestructura, pero no convierte una API externa en una operación exactamente una vez. Comprueba la semántica de retry, el almacenamiento, el comportamiento del paso interrumpido y la forma de pasar una clave al proveedor antes de confiar en el nombre del producto.

Cómo se produjo este artículo

Samuel Fajreldines es responsable de este artículo. La investigación combinó documentación actual de AWS, Microsoft y Cloudflare, inspección de resultados actuales, discusiones públicas recientes y comparación con el cluster de ejecución durable existente en el sitio. La fixture se ejecutó localmente con Node.js y usa solo archivos temporales y un proveedor falso. No llamó a un pago, email, ticket ni API externa real.

Usé asistencia de IA para descubrir fuentes, comparar páginas, organizar un primer borrador, crear la ilustración SVG y revisar la consistencia entre idiomas. La asistencia no ejecutó un agente real, no midió un sistema de producción ni sustituyó la verificación de las garantías del proveedor que uses.

Fuentes consultadas