Agent Mentor Learn
Gestión de estado y persistencia: que las tareas largas sobrevivan a una interrupción · Lección 6 de 6

Lección 6: Manos a la obra: conectar los puntos de control y la reanudación al arnés

Objetivos de aprendizaje:

  • Soldar de verdad al bucle runAgent del Curso 7 de esta serie el esquema de puntos de control «guardar la llamada colgante en el punto A, limpiarla en el punto B», en vez de dejarlo como un diagrama conceptual
  • Adosarle a runToolUses un registro de efectos secundarios: en el momento en que una herramienta tiene éxito, escribir en disco una anotación para que la reanudación pueda saber si «esta herramienta se ejecutó de verdad o no»
  • Escribir la división en tres de reconcile, y usar una corrida controlada de «muerte simulada + --resume» para ver, con tus propios ojos, que la recuperación se comporta como debería

Requisitos: Leíste las Lecciones 1 a 5 y puedes ejecutar el bucle del arnés del Curso 7 de esta serie, «Fundamentos del arnés de agente: bucles y control» | Anterior: Lección 5 <<

Primero, cómo se ve corriendo

Las primeras cinco lecciones desarmaron los puntos de control, la reanudación, la idempotencia y el par rebobinar/bifurcar, y explicaron cada pieza. Esta lección las suelda en un arnés que corre de verdad: el mismo bucle conocido —llamar al modelo con messages y, cuando stop_reason === "tool_use", ejecutar la herramienta y volver a llamar—, salvo que esta vez cada turno escribe dos puntos de control en disco, más un registro que anota los resultados de ejecución de herramientas. La tarea es «convertir notas de ventas en un reporte», llamando a tres herramientas en secuencia: read_notes, count_words, write_report. Así se ve cuando corre normalmente hasta el turno tres y ahí lo matan de golpe:

text
$ CRASH_AFTER=after-effect-write:3 node agent.js
[turn 1][save A] pending=read_notes[turn 1][save B][turn 2][save A] pending=count_words[turn 2][save B][turn 3][save A] pending=write_report[kill] caída simulada en after-effect-write:3EXIT=137

Los turnos 1 y 2 recorrieron completos los tres pasos save A → ejecutar → save B, todo normal. El turno 3 guardó save A (anotando que la llamada colgante es write_report), la herramienta de hecho terminó de ejecutarse y su resultado ya estaba escrito en el registro, pero el save B del paso siguiente nunca llegó a guardarse antes de que mataran al proceso. Esta es exactamente la ventana que esta lección sale a clavar: en este momento checkpoint.json todavía sostiene un pendingToolUse colgante. Cargando esa escena, retómalo con --resume:

text
$ node agent.js --resume
[resume] leído turn=3 pending=write_report[resume][reconcile] tool_use_id=toolu_03 name=write_report acierto en el registro, reutilizando resultado, sin volver a ejecutar[turn 3][save B] rellenado el resultado de herramienta de este turno tras reanudar[done] Reporte escrito en report.txt, tarea completa.

El flujo de reanudación lee turn=3 pending=write_report, consulta el registro y encuentra que esta llamada de hecho había terminado y quedado anotada antes de la muerte, así que reutiliza esa anotación directamente y no vuelve a ejecutar write_report, rellena el save B que le faltaba a este turno y sigue hacia el cierre del modelo como siempre. La tarea entera nunca empezó de cero, y el reporte nunca se escribió dos veces.

Estas dos salidas de terminal no son ejemplos escritos a mano. Son la salida real del script de Node impulsado por una cola de respuestas fija de la sección «El arnés de verificación» más abajo, copiadas acá línea por línea.

Construirlo bloque por bloque

Leer y escribir puntos de control: saveCheckpoint / loadCheckpoint

Un punto de control es apenas esta escena —{version, task, turns, tokensUsed, messages, pendingToolUse}— serializada a disco. Lo único con lo que hay que tener cuidado es no corromper el archivo: escribe primero en un archivo temporal y después ponlo en su lugar de forma atómica con fs.renameSync; rename es una operación indivisible dentro del mismo sistema de archivos, así que nunca hay un estado intermedio «escrito a medias»:

La lectura tiene que aguantar dos cosas: que el archivo no exista (nunca se ejecutó antes, o se quiere arrancar de cero) y que el archivo no se pueda parsear. El segundo caso merece cuidado extra: una falla de JSON.parse normalmente significa que la escritura anterior misma quedó interrumpida (saveCheckpoint es atómico en teoría, pero si matan al proceso antes de que siquiera el archivo .tmp se escriba completo, o si el disco mismo tiene un problema, se puede leer mal un archivo a medio terminar de antes del rename). En ese punto jamás hay que reiniciar el estado a vacío calladamente y hacer como si nada hubiera pasado: ahí es donde las tareas de verdad se pierden. La jugada correcta es lanzar el error de forma llana, diciéndole a la persona usuaria que este punto de control ya no es confiable y que habría que borrarlo para poder empezar de nuevo, en vez de dejar que el programa adivine su camino de vuelta a un estado entero:

Ya que estás, verifica el campo version: si la estructura del punto de control cambia más adelante, un archivo viejo no debería parsearse a la fuerza como si fuera el formato nuevo; mejor negarse a cargarlo que leer un estado medio correcto y medio equivocado. Las dos funciones se probaron con JSON truncado real: dale un {"version":1,"turns":3,"pendingT escrito a medias y loadCheckpoint lanza exactamente el error de «bórralo y empieza de nuevo» de arriba, sin devolver jamás ningún valor por defecto de apariencia plausible.

Punto A y punto B: conectarlos al bucle runAgent

El esqueleto del bucle del Curso 7 de esta serie no cambió —while (response.stop_reason === "tool_use"), push assistant → ejecutar herramienta → push tool_result → volver a pedirle al modelo—. Esta lección inserta dos puntos de control en el cuerpo del bucle, y dónde van es el punto entero de la lección:

El punto A va después de que llega response y antes de messages.push({ role: "assistant", ... }), el momento en que el modelo «nombró una herramienta pero todavía no la ejecutó», y pendingToolUse anota ese nombramiento textualmente. El punto B va después de que runToolUses termina y el tool_result quedó empujado dentro de messages; en ese punto este turno está completamente cerrado, y pendingToolUse se limpia a null. Entre los dos guardados queda intercalado exactamente el tramo de código donde la herramienta se ejecuta de verdad; si el proceso da la casualidad de morir durante ese tramo o justo después, lo que queda en disco es la escena «punto A guardado, punto B no guardado», con pendingToolUse no vacío, que es precisamente la señal que la lógica de recuperación está construida para manejar.

Para que este protocolo de una sola llamada colgante (pendingToolUse es un objeto, no un arreglo) se sostenga, esta lección diseña la tarea de modo que el modelo nombre exactamente una herramienta por turno: una simplificación deliberada cuya frontera detalla la sección «Proporción».

El registro de efectos: conectarlo a runToolUses

El problema que el registro resuelve es: si una caída aterriza justo entre «la herramienta terminó de ejecutarse de verdad» y «el resultado aterrizó en messages», ¿cómo sabe la reanudación si esta llamada ya se ejecutó y no debe volver a ejecutarse? El enfoque es que, en el momento en que una herramienta tiene éxito, su resultado se escriba aparte en un registro indexado por tool_use_id (otra vez con la escritura atómica de archivo temporal más rename):

El orden no se puede invertir: primero tienes que obtener el resultado real de toolImpls[block.name](block.input), y recién ahí saveEffect puede anotarlo; ejecutar primero, anotar después. El registro anota «esto ocurrió de verdad, y este fue su resultado». Si lo dieras vuelta y anotaras antes de ejecutar, todo lo que podría aterrizar en el registro sería un marcador de posición, y el registro perdería todo el sentido de su promesa de «ya está hecho» (el ejercicio de Nivel 2 te hace reproducir este antipatrón con tus propias manos).

En una ejecución normal y única, runToolUses recorre los dos pasos «ejecutar → anotar», porque cada tool_use_id aparece por primera vez y no hay nada que consultar. La única llamada colgante que la reanudación tiene que manejar recorre los tres pasos más completos «consultar el registro → ejecutar (si hace falta) → anotar (si se ejecutó)»; el reconcile de abajo es la implementación de esos tres pasos, y ambos siguen la misma disciplina: nunca escribir «ya está hecho» en el registro antes de tener un resultado real.

reconcile: la división en tres para una llamada colgante tras una caída

Lo que la reanudación tiene que manejar es ese único (si es que hay) pendingToolUse del punto de control. Se corresponde con tres posibilidades:

Tres ramas, para tres escenarios que se probaron todos de verdad:

  • Acierto en el registro: esta es la demostración de la caída del comienzo de la lección; write_report de hecho había terminado de ejecutarse y quedado anotado, solo que el save B no llegó. Al reanudar, reutiliza directamente el resultado del registro, no vuelvas a ejecutar, evita escribir el reporte dos veces.
  • Fallo en el registro + herramienta de solo lectura: algo como read_notes, una herramienta sin efectos secundarios; caerse antes de que la anotación aterrice no importa, así que basta con volver a ejecutarla una vez para obtener el resultado y, ya que estás, anotar esta corrida en el registro:
    text
    [resume][reconcile] tool_use_id=toolu_ro name=read_notes fallo en el registro, herramienta de solo lectura, volviendo a ejecutar
  • Fallo en el registro + efectos secundarios: algo como write_report, una herramienta que cambia estado externo, cayéndose antes de que la anotación aterrice: no sabes si de verdad se ejecutó (en un sistema de archivos real, el efecto secundario de write_report bien podría haber ocurrido ya, solo que sin quedar anotado en el registro). Acá, en vez de adivinar, usa un tool_result con is_error: true para decirle honestamente al modelo «el estado de esta llamada se desconoce», devolviéndole el juicio a él:
    text
    [resume][reconcile] tool_use_id=toolu_side name=write_report fallo en el registro con efectos secundarios, no resoluble, agregando is_error

Las tres líneas de log son salida real, no inventada: reconcile en sí no necesita saber cuál es la tarea; dale un pendingToolUse y el estado del registro que le corresponde y cada una de las tres ramas se puede probar de forma independiente.

El punto de entrada: --resume en main()

Por último, el punto de entrada. main() toma exactamente una decisión: si la línea de comandos trae --resume. Si la trae, recupera pasando por loadCheckpoint(); si no la trae, limpia los archivos de punto de control y de registro que quedaron de la vez anterior y arranca de cero. Esta limpieza garantiza que «empezar de nuevo sin --resume» sea siempre una apertura limpia, nunca contaminada por una escena a medio terminar de una corrida previa:

Dentro de runAgent hay dos caminos que se corresponden: cuando opts.resume es verdadero llama a loadCheckpoint(), ejecuta reconcile, empuja el resultado conciliado (si lo hay) dentro de messages y guarda un punto de control del punto B, y después le manda la solicitud al modelo como siempre; cuando es falso hace fs.rmSync del punto de control y del registro viejos y arranca con messages vacío. En el agent.js real, el cliente del modelo se cambia por el client.messages.create({ model, max_tokens, tools, messages }) de @anthropic-ai/sdk, y nada más de la estructura cambia.

Citar el protocolo

Ninguna de las dos decisiones de diseño de esta lección se fijó arbitrariamente.

Cuando el registro falta y reconcile no puede estar seguro del estado, elige agregar un tool_result con is_error: true en vez de saltear en silencio, apoyándose en el requisito duro del protocolo sobre el emparejamiento de bloques de contenido: cada tool_use tiene que volver con un tool_result que le corresponda, devueltos todos juntos, y cada uno reclamado por su tool_use_id1. Saltear el guardado del punto A dejaría al flujo de reanudación sin enterarse de que la llamada siquiera ocurrió, así que no podría satisfacer esa regla de emparejamiento en absoluto; el sentido entero de que reconcile exista es garantizar que, con acierto o con fallo en el registro, la llamada colgante termine con un tool_result emparejado.

Elegir «reanudar y seguir» antes que «lanzar un error y empezar de nuevo» se hace eco de lo que el equipo de ingeniería de Anthropic describió en la retrospectiva sobre su sistema de investigación: cuando ocurren errores no puedes simplemente reiniciar, porque "restarts are expensive and frustrating for users" (los reinicios son caros y frustrantes para las personas usuarias), así que en cambio "built systems that can resume from where the agent was when the errors occurred"2 (construyeron sistemas que pueden reanudar desde donde estaba el agente cuando ocurrieron los errores). La misma retrospectiva señala que la adaptabilidad de un agente se puede emparejar con salvaguardas deterministas, en vez de enfrentarse a ellas, combinando "the adaptability of AI agents built on Claude with deterministic safeguards like retry logic and regular checkpoints"2 (la adaptabilidad de los agentes de IA construidos sobre Claude con salvaguardas deterministas como la lógica de reintentos y los puntos de control regulares). Los puntos de control atrapan la falla determinista —«el proceso murió»—, mientras que la adaptabilidad del modelo maneja el tipo de caso que el código no puede decidir en duro, como «el registro no es resoluble». La rama is_error de reconcile es donde ambos se encuentran: le dice al modelo la verdad sobre el estado desconocido y lo deja decidir si verificar o reintentar, y "letting the agent know when a tool is failing and letting it adapt works surprisingly well"2 (hacerle saber al agente cuándo una herramienta está fallando y dejarlo adaptarse funciona sorprendentemente bien).

El arnés de verificación

Las dos demostraciones de terminal de esta lección no dependen de matar de verdad un proceso para ver qué pasa: así el momento de la caída sería distinto en cada corrida, y no podrías hacer una afirmación puntual como «la caída ocurrió después de la enésima llamada a herramienta, y el comportamiento de recuperación es correcto». El enfoque es cambiar el cliente del modelo por un stub que juega sus cartas en un orden fijo: una cola de respuestas que, en cada llamada a messages.create, entrega la siguiente respuesta preescrita en secuencia, y lanza una excepción sin más si sigues llamando después de que la cola se vació. Así, qué herramienta llama la tarea en qué turno, y cuándo cierra el modelo, son todas constantes fijas que no se corren por culpa de una llamada real.

«Matar al proceso» es un crashPoint(label) controlado por una variable de entorno: cada vez que runToolUses termina una escritura en el registro, cose «cuál escritura es esta» en una etiqueta de texto, la compara contra la variable de entorno CRASH_AFTER y, si coincide, lanza una excepción dedicada SimulatedCrash. Esto convierte «caerse después de la enésima llamada a herramienta» en un entero que puedes especificar con precisión, en vez de un evento al azar a merced del temporizado. main() atrapa solo esta única excepción en la capa más externa, imprime una sola línea de log [kill] y sale con 137 (el código de salida convencional de «matado por SIGKILL»), de modo que la demostración se lee como una muerte de proceso real y no como una traza de pila fea.

Este método de «fijar el contenido con una cola de respuestas, fijar el conteo de caídas con una etiqueta» es la misma idea que usó la Lección 6 del Curso 8 de esta serie, «Ingeniería de contexto: gastar una atención finita donde más rinde», para verificar la ingeniería de contexto: fija primero como cantidades constantes las cosas que de otro modo serían no deterministas (qué dice el modelo esta vez, dónde muere el proceso esta vez), y recién entonces el comportamiento de recuperación se puede afirmar línea por línea en vez de salir distinto en cada corrida. Así fue como esta lección verificó las tres ramas —«acierto en el registro, no volver a ejecutar», «fallo en el registro con herramienta de solo lectura, volver a ejecutar directamente», «fallo en el registro con herramienta de efectos secundarios, agregar is_error»— más la tolerancia de loadCheckpoint a un archivo truncado, cada una comprobada una por una con una corrida real de node y no razonada solo sobre el papel.

Proporción: no toda tarea necesita esto

La maquinaria soldada en esta lección —dos puntos de control, un registro, un reconcile de tres ramas— está pensada para tareas largas que corren muchos turnos seguidos y tienen efectos secundarios en el medio. En una tarea chica que termina en unos segundos y que simplemente se puede volver a ejecutar si falla, puede no valer la pena cargar con todo este aparato de E/S a disco y máquina de estados; acá puedes tomar prestada la misma proporción que citó el Curso 7 de esta serie, «Fundamentos del arnés de agente: bucles y control»: lo que vale la pena considerar es que "you should consider adding complexity only when it demonstrably improves outcomes"3 (habría que considerar agregar complejidad solo cuando mejora los resultados de forma demostrable). Esta no es una regla dura de «tienes que hacerlo así», es más bien una pregunta para hacerte antes de arrancar: ¿esta tarea es de verdad lo bastante larga, lo bastante importante, como para que valga la pena mantenerle un punto de control?

La implementación de esta lección también traza dos fronteras explícitas, que vale la pena decir en voz alta para que no la tomes como «apréndelo y tíralo directo a producción»:

  • Cada turno maneja exactamente un pendingToolUse colgante, lo que coincide con la tarea de demostración donde el modelo nombra una herramienta por turno. En un entorno real, una sola respuesta del modelo bien podría cargar varios bloques tool_use concurrentes (el runToolUses del Curso 7 de esta serie los ejecuta en paralelo con Promise.all); extender el protocolo de una sola llamada colgante de esta lección a un conjunto de llamadas colgantes significa convertir pendingToolUse de un objeto en un arreglo y ejecutar reconcile sobre cada uno. Esta lección dejó afuera esa capa de complejidad a propósito, para primero dejar clara la lógica de conciliación de una sola llamada colgante.
  • El punto de control y el registro de esta lección gobiernan una cosa: «un proceso, ejecutando una tarea». Cómo comparten estado varias sesiones, si varios procesos que tocan el mismo punto de control a la vez entran en conflicto, cómo se garantiza la consistencia entre máquinas: eso pertenece a la concurrencia multisesión y a la consistencia distribuida, y no está en esta lección ni en el alcance de este curso.

Resumen

  • El punto de control guarda dos veces por turno: el punto A anota el pendingToolUse colgante después de que llega la respuesta del modelo, el punto B lo limpia a null después de que el resultado de herramienta aterriza en messages; guardar solo el punto B vuelve completamente invisible en el punto de control la ventana entre «el modelo nombra una herramienta» y «el resultado queda anotado» y, como cada tool_use tiene que volver emparejado con un tool_result1, el punto A es exactamente lo que hace rastreable la llamada colgante dentro de esa ventana
  • El registro de efectos secundarios anota por tool_use_id, y su disciplina es «ejecutar primero, anotar después»: anotar tiene como premisa tener ya un resultado real; inviértelo y anotas mal «todavía no ejecutada» como «ya hecha»
  • La división en tres de reconcile maneja la llamada colgante al reanudar: acierto en el registro, reutilizar y no volver a ejecutar; fallo en el registro pero de solo lectura, volver a ejecutar directamente; fallo en el registro con efectos secundarios, no adivinar y agregar un tool_result con is_error que le devuelva el estado honestamente al modelo. Esto se hace eco de las dos lecciones de ingeniería de «ante un error no puedes empezar de cero, tienes que reanudar desde donde golpeó» y «hazle saber al modelo que una herramienta falló, déjalo adaptarse, y funciona sorprendentemente bien»2, y se alinea con la idea de «salvaguardas deterministas emparejadas con la adaptabilidad del modelo»2
  • La maquinaria de punto de control y registro no es gratis; agrégala solo cuando la complejidad mejora los resultados de forma demostrable3; la implementación de esta lección gobierna solo «un proceso ejecutando una tarea», y la concurrencia multisesión y la consistencia distribuida no están entre sus preocupaciones ni en el alcance de este curso

Con esto terminaste este curso. Partiendo del juicio de que «los agentes tienen estado y los errores se componen», trabajaste todo el camino a través de qué debería guardar un punto de control, cuándo escribirlo en disco, cómo manejar una llamada colgante al reanudar, cómo la idempotencia respalda la recuperación y cómo un punto de control puede servir además para rebobinar y bifurcar, hasta esta lección, donde los soldaste a mano en un arnés que corre de verdad, que de verdad lo matan y que de verdad retoma y termina. Lo que tienes ahora no es apenas un conjunto de conceptos, sino un tramo de código verificado con ejecuciones reales de node. Conéctalo a tu propio arnés y, la próxima vez que de verdad lo maten, va a retomar justo desde donde lo dejó.

Footnotes

  1. Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls 2

  2. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5

  3. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2

Ejercicios

01

Dentro de un turno, los puntos de control de esta lección pueden atrapar como mucho tres «momentos de caída»: ① el punto A recién guardado, la herramienta todavía no empezó a ejecutarse; ② la función de implementación de la herramienta ya terminó, pero el registro todavía no se escribió; ③ el punto B recién guardado. Contra el saveCheckpoint / saveEffect / reconcile que implementa esta lección, escribe cada uno de esos momentos con claridad: después de la caída, en qué estado quedan checkpoint.json y effects.json respectivamente; al hacer --resume, en qué rama aterriza reconcile; y si lo que quedó interrumpido fue una herramienta con efectos secundarios, no de solo lectura (digamos write_report), si los estados observables en disco de ① y ② son iguales, y si el comportamiento de recuperación es el mismo, y si lo son, qué te dice eso.

Nivel 1: Un manual de simulacro para el momento de la caída
Criterios de finalización · marcado local
02

El reporte de incidente dice: «La persona usuaria interrumpió una tarea y, después de --resume, una herramienta quedó salteada: el log la mostraba como "ya hecha", pero esta herramienta nunca se ejecutó de verdad, y el archivo que debía escribir simplemente no existe». Desenterraste el runToolUses que estaba corriendo en producción en ese momento y encuentras una diferencia con la versión de esta lección:

Nivel 2: Encontrar el error de orden donde el registro se escribió al revés

Encuentra este error de orden, explica con claridad por qué causa que «una herramienta que claramente nunca se ejecutó se trate como hecha», y corrige el orden. Después, siguiendo el método de la sección «El arnés de verificación» de esta lección, escribe un script chico que lo reproduzca: inserta un punto de caída simulada controlado por variable de entorno entre saveEffect y toolImpls[block.name](...), y ejecútalo de verdad con node; con el orden equivocado, el registro ya sostiene una anotación con result: null antes de la caída; con el orden corregido, el mismo punto de caída no deja ninguna anotación para este tool_use_id en el registro.

Criterios de finalización · marcado local