Lição 6: Mão na massa: soldando checkpoint e retomada no harness
Objetivos de aprendizado:
- Soldar de fato o esquema de checkpoint “salvar a chamada órfã no ponto A, limpá-la no ponto B” dentro do loop
runAgentdo Curso 7 desta série, em vez de deixá-lo como diagrama conceitual- Anexar um livro-razão de efeitos colaterais ao
runToolUses: no instante em que uma ferramenta tem sucesso, escrever um registro em disco para que a retomada consiga dizer se “esta ferramenta de fato rodou ou não”- Escrever a divisão em três vias do
reconcilee usar uma execução controlada de “kill simulado +--resume” para ver, com os próprios olhos, a recuperação se comportar como devePré-requisitos: você leu as Lições 1-5 e sabe rodar o loop do harness do Curso 7 desta série, "Agent Harness Fundamentals: Loops and Control" | Anterior: Lição 5 <<
Veja rodar primeiro
As cinco primeiras lições separaram checkpoints, retomada, idempotência e rebobinagem/bifurcação e explicaram cada um. Esta lição solda tudo em um harness que roda de verdade: o mesmo loop familiar — chamar o modelo com messages e, quando stop_reason === "tool_use", executar a ferramenta e chamar de novo — exceto que desta vez cada turno escreve dois checkpoints em disco, mais um livro-razão registrando os resultados de execução das ferramentas. A tarefa é “transformar notas de vendas em um relatório”, chamando três ferramentas em sequência: read_notes, count_words, write_report. Eis como fica rodando normalmente até o terceiro turno e sendo morto de imediato:
Os turnos 1 e 2 percorreram os três passos completos save A → executar → save B, tudo normal. O turno 3 salvou save A (registrando que a chamada órfã é write_report), a ferramenta de fato terminou de executar e seu resultado já tinha sido escrito no livro-razão — mas o save B do passo seguinte nunca chegou a ser salvo antes de o processo ser morto. Esta é exatamente a janela que esta lição quer cravar: neste momento, o checkpoint.json ainda guarda um pendingToolUse órfão. Carregando essa cena, retome com --resume:
O fluxo de retomada lê turn=3 pending=write_report, consulta o livro-razão — e descobre que esta chamada de fato tinha terminado e sido registrada antes do kill, então reusa aquele registro diretamente e não reexecuta write_report, preenche o save B que faltava neste turno e segue para o fechamento do modelo como de costume. A tarefa inteira nunca recomeçou do zero, e o relatório nunca foi escrito duas vezes.
Estas duas saídas de terminal não são exemplos escritos à mão. São a saída real do script Node conduzido por uma fila fixa de respostas na seção “O aparato de verificação” mais abaixo, copiadas aqui linha por linha.
Montando bloco a bloco
Lendo e escrevendo checkpoints: saveCheckpoint / loadCheckpoint
Um checkpoint é só esta cena — {version, task, turns, tokensUsed, messages, pendingToolUse} — serializada em disco. A única coisa com que tomar cuidado é não corromper o arquivo: escreva primeiro em um arquivo temporário, depois troque-o de lugar atomicamente com fs.renameSync — rename é uma operação indivisível dentro do mesmo sistema de arquivos, então nunca há um estado intermediário “escrito pela metade”:
A leitura tem de aguentar duas coisas: o arquivo não existir (nunca rodou antes, ou a intenção é começar do zero) e o arquivo falhar na análise. O segundo caso merece cuidado extra — uma falha de JSON.parse em geral significa que a escrita anterior foi ela própria interrompida (saveCheckpoint é atômico em tese, mas se o processo for morto antes mesmo de o arquivo .tmp estar completamente escrito, ou se o próprio disco tiver um problema, um arquivo inacabado de antes do rename pode acabar sendo lido por engano). Nesse ponto, você jamais pode zerar silenciosamente o estado e fingir que nada aconteceu — é aí que as tarefas de fato se perdem. A jogada certa é lançar o erro sem rodeios, dizendo à pessoa que este checkpoint não é mais confiável e deveria ser apagado para que ela possa começar de novo, em vez de deixar o programa adivinhar seu caminho de volta a um estado íntegro:
Já que está aqui, cheque o campo version: se a estrutura do checkpoint mudar mais adiante, um arquivo antigo não deveria ser analisado à força como o formato novo — melhor recusar carregar do que ler um estado meio certo e meio errado. Ambas as funções foram testadas com JSON truncado de verdade: alimente-as com um {"version":1,"turns":3,"pendingT escrito pela metade e loadCheckpoint lança exatamente o erro de “apague e comece de novo” acima, sem nunca devolver nenhum padrão de aparência plausível.
Ponto A e ponto B: ligando-os ao loop runAgent
O esqueleto do loop do Curso 7 desta série não mudou — while (response.stop_reason === "tool_use"), push assistant → executar ferramenta → push tool_result → requisitar o modelo de novo. Esta lição insere dois checkpoints no corpo do loop, e onde eles vão é o ponto central da lição:
O ponto A vai depois de response chegar e antes de messages.push({ role: "assistant", ... }) — o momento em que o modelo “nomeou uma ferramenta mas não a executou de fato”, e pendingToolUse registra essa nomeação literalmente. O ponto B vai depois de runToolUses terminar e o tool_result ter sido empurrado para messages — nesse ponto este turno está inteiramente encerrado, e pendingToolUse é limpo para null. Ensanduichado entre os dois salvamentos está exatamente o trecho de código em que a ferramenta de fato executa; se o processo por acaso morrer durante esse trecho ou logo depois dele, o que fica em disco é a cena “ponto A salvo, ponto B não salvo” — pendingToolUse não vazio, que é precisamente o sinal que a lógica de recuperação foi feita para tratar.
Para que este protocolo de chamada órfã única (pendingToolUse é um objeto, não um array) se sustente, esta lição projeta a tarefa de modo que o modelo nomeie exatamente uma ferramenta por turno — uma simplificação deliberada cujos limites a seção “Proporção” detalha.
O livro-razão de efeitos: ligando-o ao runToolUses
O problema que o livro-razão resolve é: se uma queda cair bem entre “a ferramenta de fato terminou de executar” e “o resultado caiu em messages”, como a retomada sabe se esta chamada já rodou e não pode rodar de novo. A abordagem é, no instante em que uma ferramenta tem sucesso, escrever o resultado dela separadamente em um livro-razão indexado por tool_use_id (de novo com a gravação atômica de arquivo temporário mais rename):
A ordem não pode ser trocada: você tem de obter primeiro o resultado real de toolImpls[block.name](block.input) e só então saveEffect pode anotá-lo — executar primeiro, registrar depois. O livro-razão registra “esta coisa realmente aconteceu, e este foi o resultado dela”. Se você invertesse e registrasse antes de executar, tudo o que poderia cair no livro-razão seria um marcador, e o livro-razão perderia todo o sentido da sua promessa de “já feito” (o exercício de Nível 2 faz você reproduzir este antipadrão com as próprias mãos).
Em uma execução normal única, runToolUses percorre os dois passos “executar → registrar”, porque cada tool_use_id aparece pela primeira vez, sem nada a consultar. A única chamada órfã que a retomada tem de tratar percorre os três passos mais completos “consultar o livro-razão → executar (se preciso) → registrar (se executou)” — o reconcile abaixo é a implementação desses três passos, e ambos seguem a mesma disciplina: nunca escreva “já feito” no livro-razão antes de ter um resultado real.
reconcile: a divisão em três vias para uma chamada órfã depois de uma queda
O que a retomada tem de tratar é aquele único (se houver) pendingToolUse do checkpoint. Ele corresponde a três possibilidades:
Três ramos, para três cenários que foram todos realmente testados:
- Acerto no livro-razão — esta é a demonstração de queda do começo da lição:
write_reportde fato tinha terminado de executar e sido registrada, só osave Bnão conseguiu. Na retomada, reuse diretamente o resultado do livro-razão, não reexecute, evite escrever o relatório duas vezes. - Falha no livro-razão + ferramenta somente leitura — algo como
read_notes, uma ferramenta sem efeitos colaterais; cair antes de o registro entrar não importa, então é só reexecutá-la uma vez para obter o resultado e, já que está aqui, registrar esta execução no livro-razão: - Falha no livro-razão + efeitos colaterais — algo como
write_report, uma ferramenta que muda o estado externo, caindo antes de o registro entrar: você não sabe se ela de fato rodou (em um sistema de arquivos real, o efeito colateral dewrite_reportbem pode já ter acontecido, só sem ter sido registrado no livro-razão). Aqui, em vez de adivinhar, use umtool_resultcomis_error: truepara dizer honestamente ao modelo “o estado desta chamada é desconhecido”, devolvendo o julgamento a ele:
Todas as três linhas de log são saída real, não inventada — o próprio reconcile não precisa saber qual é a tarefa; dê a ele um pendingToolUse e o estado correspondente do livro-razão e cada um dos três ramos é testável de forma independente.
O ponto de entrada: --resume no main()
Por último, o ponto de entrada. main() toma exatamente uma decisão: a linha de comando tem --resume. Se tiver, recupere via loadCheckpoint(); se não tiver, limpe os arquivos de checkpoint e livro-razão que sobraram da última vez e comece do zero — essa limpeza garante que “começar de novo sem --resume” seja sempre uma abertura limpa, nunca poluída por uma cena inacabada de uma execução anterior:
Dentro de runAgent há dois caminhos correspondentes: quando opts.resume é verdadeiro, ele chama loadCheckpoint(), roda reconcile, empurra o resultado reconciliado (se houver) para messages e salva um checkpoint do ponto B, e então envia a requisição ao modelo como de costume; quando é falso, ele faz fs.rmSync do checkpoint e do livro-razão antigos e começa de um messages vazio. No agent.js real, o cliente do modelo é trocado pelo client.messages.create({ model, max_tokens, tools, messages }) do @anthropic-ai/sdk, e nada mais na estrutura muda.
Citando o protocolo
Nenhuma das duas decisões de projeto desta lição foi tomada arbitrariamente.
Quando o livro-razão está ausente e o reconcile não consegue ter certeza do estado, ele escolhe anexar um tool_result com is_error: true em vez de pular silenciosamente — apoiando-se na exigência dura do protocolo sobre emparelhamento de blocos de conteúdo: todo tool_use tem de voltar com um tool_result correspondente, todos devolvidos juntos, cada um reivindicado pelo seu tool_use_id1. Pular o salvamento do ponto A deixaria o fluxo de retomada sem saber que a chamada algum dia aconteceu, de modo que ele não conseguiria satisfazer aquela regra de emparelhamento de jeito nenhum; todo o sentido de o reconcile existir é garantir que, com acerto ou falha no livro-razão, a chamada órfã termine com um tool_result emparelhado.
Escolher “retomar e seguir” em vez de “dar erro e começar de novo” ecoa o que a equipe de engenharia da Anthropic descreveu na retrospectiva sobre seu sistema de pesquisa: quando ocorrem erros, você não pode simplesmente reiniciar, porque "restarts are expensive and frustrating for users" (reinícios são caros e frustrantes para os usuários), então em vez disso eles "built systems that can resume from where the agent was when the errors occurred"2 (construíram sistemas capazes de retomar de onde o agente estava quando os erros ocorreram). A mesma retrospectiva observa que a adaptabilidade de um agente pode ser emparelhada com — em vez de posta contra — salvaguardas determinísticas, combinando "the adaptability of AI agents built on Claude with deterministic safeguards like retry logic and regular checkpoints"2 (a adaptabilidade de agentes de IA construídos sobre o Claude com salvaguardas determinísticas como lógica de retentativa e checkpoints regulares). Checkpoints pegam a falha determinística — “o processo morreu” —, enquanto a adaptabilidade do modelo trata o tipo de caso que o código não consegue decidir de forma dura, como “o livro-razão é indeterminável”. O ramo is_error do reconcile é onde os dois se encontram: ele conta ao modelo a verdade sobre o estado desconhecido e o deixa decidir se verifica ou tenta de novo, e "letting the agent know when a tool is failing and letting it adapt works surprisingly well"2 (deixar o agente saber quando uma ferramenta está falhando e deixá-lo se adaptar funciona surpreendentemente bem).
O aparato de verificação
As duas demonstrações de terminal desta lição não dependem de matar de fato um processo para ver o que acontece — desse jeito o momento da queda seria diferente a cada execução, e você não conseguiria fazer uma afirmação dirigida como “a queda aconteceu depois da enésima chamada de ferramenta, e o comportamento de recuperação está correto”. A abordagem é trocar o cliente do modelo por um dublê que joga as cartas em uma ordem fixa: uma fila de respostas que, a cada chamada a messages.create, entrega em sequência a próxima resposta pré-escrita, e lança erro de imediato se você continuar chamando depois de a fila esvaziar — de modo que qual ferramenta a tarefa chama em qual turno, e quando o modelo fecha, são todas constantes fixas que não se deslocam por causa de uma chamada real.
“Matar o processo” é um crashPoint(label) controlado por uma variável de ambiente: toda vez que runToolUses termina uma gravação no livro-razão, ele costura “qual escrita é esta” em um rótulo de texto, compara-o com a variável de ambiente CRASH_AFTER e, em uma correspondência, lança uma exceção dedicada SimulatedCrash. Isso transforma “cair depois da enésima chamada de ferramenta” em um inteiro que você pode especificar com precisão, em vez de um evento fortuito à mercê do relógio. main() captura apenas esta única exceção na camada mais externa, imprime uma única linha de log [kill] e sai com 137 (o código de saída convencional para “morto por SIGKILL”), de modo que a demonstração se lê como um processo morto de verdade em vez de um stack trace feio.
Este método de “fixar o conteúdo com uma fila de respostas, fixar a contagem de quedas com um rótulo” é a mesma ideia que a Lição 6 do Curso 8 desta série, "Context Engineering: Spending Finite Attention Where It Counts", usou para verificar engenharia de contexto: fixe primeiro em quantidades determinadas as coisas que de outro modo seriam não determinísticas (o que o modelo diz desta vez, onde o processo morre desta vez), e só então o comportamento de recuperação pode ser afirmado linha por linha em vez de sair diferente a cada execução. Foi assim que esta lição verificou todos os três ramos — “acerto no livro-razão, não reexecutar”, “falha no livro-razão em ferramenta somente leitura, reexecutar direto”, “falha no livro-razão em ferramenta com efeitos colaterais, anexar is_error” — mais a tolerância do loadCheckpoint a um arquivo truncado, cada um conferido um a um com uma execução real de node em vez de raciocinado apenas no papel.
Proporção: nem toda tarefa precisa disto
A maquinaria soldada nesta lição — dois checkpoints, um livro-razão, um reconcile de três vias — é feita para tarefas longas que rodam muitos turnos seguidos e têm efeitos colaterais pelo caminho. Uma tarefa pequena que termina em alguns segundos e pode simplesmente ser reexecutada em caso de falha talvez não valha carregar todo este aparato de I/O de disco e máquina de estados; aqui você pode tomar emprestada a mesma proporção que o Curso 7 desta série, "Agent Harness Fundamentals: Loops and Control", citou: o que vale considerar é que "you should consider adding complexity only when it demonstrably improves outcomes"3 (você deveria considerar acrescentar complexidade apenas quando isso comprovadamente melhorar os resultados). Isto não é uma regra dura de “você tem de fazer assim”, e sim uma pergunta a se fazer antes de começar: esta tarefa é mesmo longa o bastante, mesmo importante o bastante, para valer manter um checkpoint?
A implementação desta lição também traça dois limites explícitos, que vale dizer em voz alta para você não a tratar como “aprendi e já jogo direto em produção”:
- Cada turno trata exatamente um
pendingToolUseórfão, condizente com a tarefa de demonstração em que o modelo nomeia uma ferramenta por turno. Em um cenário real, uma única resposta do modelo bem pode carregar vários blocostool_useconcorrentes (orunToolUsesdo Curso 7 desta série os roda concorrentemente comPromise.all); estender o protocolo de chamada órfã única desta lição para um conjunto de chamadas órfãs significa transformarpendingToolUsede objeto em array e rodarreconcilesobre cada um. Esta lição deixou de fora essa camada de complexidade de propósito, para primeiro deixar clara a lógica de reconciliação de uma única chamada órfã. - O checkpoint e o livro-razão desta lição governam uma coisa: “um processo, rodando uma tarefa”. Como várias sessões compartilham estado, se vários processos tocando o mesmo checkpoint ao mesmo tempo conflitam, como a consistência entre máquinas é garantida — isso pertence a concorrência multissessão e consistência distribuída, e não está nesta lição, nem no escopo deste curso.
Recapitulação
- O checkpoint salva duas vezes por turno: o ponto A registra o
pendingToolUseórfão depois que a resposta do modelo chega, o ponto B o limpa para null depois que o resultado da ferramenta cai emmessages; salvar só o ponto B torna a janela entre “o modelo nomeia uma ferramenta” e “o resultado está registrado” completamente invisível no checkpoint e, como todotool_usetem de voltar emparelhado com umtool_result1, o ponto A é exatamente o que torna rastreável a chamada órfã dentro daquela janela - O livro-razão de efeitos colaterais registra por
tool_use_id, e a disciplina dele é “executar primeiro, registrar depois” — registrar tem como premissa já se ter um resultado real; inverta e você registra erroneamente “ainda não rodou” como “já feito” - A divisão em três vias do
reconciletrata a chamada órfã na retomada: acerto no livro-razão, reusar e não reexecutar; falha no livro-razão mas somente leitura, reexecutar direto; falha no livro-razão com efeitos colaterais, não adivinhar, anexar umtool_resultcomis_errordevolvendo o estado honestamente ao modelo — isso ecoa as duas lições de engenharia de “você não pode reiniciar do zero em caso de erro, tem de retomar de onde parou” e “deixe o modelo saber que uma ferramenta falhou, deixe que ele se adapte, e funciona surpreendentemente bem”2, e se alinha à ideia de “salvaguardas determinísticas emparelhadas com a adaptabilidade do modelo”2 - A maquinaria de checkpoint e livro-razão não é de graça; acrescente-a apenas quando a complexidade comprovadamente melhorar os resultados3; a implementação desta lição governa somente “um processo rodando uma tarefa”, e concorrência multissessão e consistência distribuída não estão entre suas preocupações, nem no escopo deste curso
Você concluiu este curso. Partindo do julgamento de que “agentes têm estado e os erros se acumulam”, você trabalhou todo o caminho — o que um checkpoint deve salvar, quando escrevê-lo em disco, como tratar uma chamada órfã na retomada, como a idempotência dá rede de proteção à recuperação e como um checkpoint pode ainda servir à rebobinagem e à bifurcação — até esta lição, em que você os soldou à mão em um harness que roda de verdade, é morto de verdade e de verdade retoma e termina. O que você tem agora não é só um conjunto de conceitos, mas um trecho de código verificado por execução real de node. Ligue-o ao seu próprio harness e, da próxima vez que ele de fato for morto, vai retomar exatamente de onde parou.
Footnotes
-
Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2
-
How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system ↩ ↩2 ↩3 ↩4 ↩5
-
Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents ↩ ↩2
Exercícios
O relato do incidente diz: “A pessoa interrompeu uma tarefa e, depois do --resume, uma ferramenta foi pulada — o log a mostrava como ‘já feita’, mas esta ferramenta nunca foi de fato executada, e o arquivo que ela deveria escrever simplesmente não existe.” Você desenterra o runToolUses que estava rodando em produção na época e acha uma diferença em relação à versão desta lição:
Nível 2: Ache o erro de ordem em que o livro-razão foi escrito ao contrárioAche este erro de ordem, explique com clareza por que ele causa “uma ferramenta que claramente nunca rodou é tratada como feita” e corrija a ordem. Depois, seguindo o método da seção “O aparato de verificação” desta lição, escreva um pequeno script para reproduzi-lo: insira um ponto de queda simulada controlado por variável de ambiente entre saveEffect e toolImpls[block.name](...), e rode-o de verdade com node — sob a ordem errada, o livro-razão já guarda um registro result: null antes da queda; com a ordem corrigida, o mesmo ponto de queda não deixa entrada nenhuma para este tool_use_id no livro-razão.