Pular para o conteúdo principal

O worker morreu no meio do turno: recuperação de execuções órfãs em agentes com checkpoint

· 7 min para ler
Gui
AI Engineer · Engenharia de Sistemas Multiagentes
Worker cai no meio do turno; execução fica RUNNING com checkpoint parcial; reaper com heartbeat move para PENDING e outra réplica retoma

A dor do turno órfão​

O usuário manda uma mensagem. O worker pega o turno, abre o stream, começa a gerar tokens, grava checkpoint parcial, e morre. OOM, deploy rolling, SIGKILL, nó evicted. Do lado do banco, a execução continua RUNNING. Do lado do usuário, o canal fica mudo.

Com uma única réplica, alguém reinicia o processo e espera o melhor. Com várias, o stream (fila, webhook redelivery, pub/sub) reentrega a mensagem para outra réplica. Essa réplica tenta resume no mesmo thread_id, encontra estado incompleto, disputa ownership com um fantasma que ainda “parece” dono do turno, e o resultado típico é: resposta duplicada, efeito colateral repetido, ou silêncio permanente enquanto a linha fica presa em RUNNING.

O Ciclo 1 desta série tratou como pinar a rota LLM. O Ciclo 2 tratou quando chamar o modelo. Este texto trata do terceiro eixo: o que fazer quando o processo que estava no meio do turno some.

Por que o checkpoint não basta​

Checkpointer (LangGraph e equivalentes) persiste snapshot por thread_id a cada superstep. Pending writes salvam o que nodes irmãos já concluíram no mesmo passo. Isso é necessário, e insuficiente.

Persistência responde: “qual era o estado?”. Não responde: “o processo que estava avançando ainda está vivo?”. Não detecta crash. Não elege quem resume. Não impede duas réplicas de invocarem o mesmo thread_id ao mesmo tempo. A documentação pública de frameworks de grafo é explícita: retomar é responsabilidade do caller. Em produção always-on, o caller precisa ser um supervisor, não um humano no dashboard.

Suspend/resume e interrupt humano (HITL) resolvem espera deliberada: o grafo pausa, o estado fica estável, alguém retoma com Command(resume=…). Crash de worker é outra classe: o grafo não pediu pausa; o processo sumiu no meio de um superstep. Tratar crash como se fosse interrupt é confundir intenção com falha.

Checkpoint sem detecção de morte vira mausoléu: estado bonito, execução morta.

Heartbeat no loop de streaming​

Enquanto o turno corre, o worker precisa provar vida em intervalo curto, tipicamente a cada poucos segundos, dentro do loop que consome o stream do modelo ou avança o grafo. O heartbeat atualiza um lease: last_heartbeat_at, worker_id, talvez um token de geração.

Sem heartbeat no caminho quente, o reaper só enxerga timeout grosso (“ninguém tocou essa linha há N minutos”) e confunde turno longo legítimo com órfão. Com heartbeat, a distinção fica operacional: streaming ativo com lease fresco ≠ processo morto com lease expirado.

Regra prática: o mesmo código path que escreve tokens no canal (ou avança o checkpointer) renova o lease. Heartbeat em thread paralela “esquecida” no meio de um await longo falha no momento em que você mais precisa, exatamente quando a tool ou o stream trava o event loop e o processo ainda parece saudável para o SO, mas o lease envelhece.

Valores ilustrativos (não de produção): renovar a cada ~5-15s; considerar órfão após ~2-3× o intervalo sem renovação. O número certo depende da pior latência esperada entre heartbeats sob carga, não de um default de blog.

Reaper com eleição de líder​

O reaper é um loop periódico que varre execuções RUNNING com lease expirado e as devolve para retentativa. Em cluster, só um reaper deve decidir por vez, senão dois líderes “recuperam” a mesma linha e você recria a corrida que queria eliminar.

Eleição simples e suficiente: lease de líder no banco (linha única reaper_leader com holder + expires_at), renovado pelo detentor; quem não detém não reapera. Alternativa equivalente: lock distribuído com TTL. O importante é a invariante: uma decisão de orfandade por execução por ciclo.

O reaper não “conserta” o grafo. Ele só muda o contrato de ownership: esta execução deixou de ter dono vivo; pode ser reivindicada. O resume em si continua sendo invoke/ainvoke no mesmo thread_id a partir do último checkpoint consistente, com efeitos pré-crash idempotentes, a mesma lição do interrupt durável.

DML condicional e poison pill​

A transição RUNNING → PENDING (ou RUNNING → RETRY) tem de ser um único UPDATE condicional. Pseudocódigo de produção:

UPDATE agent_runs
SET status = 'PENDING',
attempt = attempt + 1,
worker_id = NULL,
claimed_at = NULL
WHERE id = :run_id
AND status = 'RUNNING'
AND last_heartbeat_at < :stale_before
AND attempt < :max_attempts;

Se rowcount = 0, outra réplica já reclaimou, o heartbeat voltou, ou o teto de tentativas estourou. Não há “quase”: ou você ganhou a linha, ou não.

O limite de tentativas é a única saída lateral de uma máquina de estados que, no restante, só anda para frente (PENDING → RUNNING → SUCCEEDED / CANCELLED). Sem teto, um turno que sempre falha no mesmo node vira poison pill: reaper reclama, worker morre de novo, reaper reclama: custo e ruído sem progresso. Com teto, a linha vai para FAILED (ou dead-letter) com causa gravada; alerta humano ou política de produto decide o próximo passo.

Republicações duplicadas do canal (at-least-once) caem na mesma serialização: por conversa/turno, só uma execução RUNNING por vez; inbound com a mesma chave de idempotência reusa o resultado já persistido em vez de abrir segundo grafo.

Cancelamento como fonte única de verdade​

Cancelar no processo (“flag in-memory”, fechar o stream local) não basta quando o processo pode morrer ou quando outra réplica assume. A fonte de verdade do cancelamento vive no banco: coluna cancel_requested_at / status CANCELLED, lida no início de cada superstep e no loop de heartbeat.

Fluxo mínimo:

  1. API ou operador marca cancelamento durável.
  2. Worker atual, se vivo, vê a flag no próximo heartbeat ou boundary de node e encerra com limpeza.
  3. Se o worker já morreu, o reaper encontra RUNNING órfão e cancelamento pedido → transição direta para CANCELLED, sem retentar.

HITL e interrupt continuam no eixo de espera: o grafo pausa de propósito. Cancelamento é eixo de aborto: o turno não deve retomar. Misturar os dois no mesmo bit (“paused_or_dead”) produz resume que o usuário já cancelou e cancelamento que o reaper interpreta como órfão recuperável.

Anti-padrões​

  • Confiar só no checkpoint. Estado no Postgres, processo no cemitério, ninguém resume.
  • Reaper sem líder. Duas réplicas “recuperam” o mesmo run_id; efeito colateral em dobro.
  • UPDATE sem predicado. SET status='PENDING' WHERE id=? ignora heartbeat fresco e attempt, corrida clássica.
  • Sem teto de tentativas. Poison pill eterna com custo de LLM e de fila.
  • Heartbeat fora do caminho quente. Thread auxiliar que morre calada enquanto o stream principal trava.
  • Cancelamento só no processo. Réplica nova retoma turno que o usuário já abortou.
  • Tratar crash como interrupt. UX de “aguardando humano” para falha de infra; ou pior, auto-resume de cancelamento.
  • Ignorar idempotência de efeitos. Node que cobrou, enviou e-mail ou abriu ticket antes do crash roda de novo no resume sem recibo durável.

Conclusão acionável​

  1. Separe persistência de estado (checkpoint) de detecção de morte (heartbeat + lease).
  2. Coloque renovação de lease no mesmo loop que avança o turno / stream.
  3. Rode um reaper com eleição de líder; um decisor por ciclo.
  4. Faça RUNNING → PENDING com UPDATE condicional (status + stale + attempt); respeite rowcount.
  5. Defina teto de tentativas como única saída lateral para poison pill.
  6. Grave cancelamento no banco; workers e reaper leem a mesma flag.
  7. Mantenha serialização de turno + idempotência de inbound para absorver redelivery.
  8. No resume, reexecute só o que não está commitado: efeitos pré-crash idempotentes, como no interrupt durável.

Referências públicas úteis: checkpointers LangGraph (pending writes, resume por thread_id, limites do que a persistência não faz); interrupts (Command(resume=…)); discussões de durable execution versus checkpoint-only; e os artigos anteriores desta série: rotas LLM explícitas por node e roteamento determinístico vs modelo.

Se o sistema não consegue distinguir “turno longo legítimo” de “worker morto com lease expirado”, a próxima mensagem do usuário não é produto, é loteria de réplica.