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

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:
- API ou operador marca cancelamento durável.
- Worker atual, se vivo, vê a flag no próximo heartbeat ou boundary de node e encerra com limpeza.
- Se o worker já morreu, o reaper encontra
RUNNINGórfão e cancelamento pedido → transição direta paraCANCELLED, 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
- Separe persistência de estado (checkpoint) de detecção de morte (heartbeat + lease).
- Coloque renovação de lease no mesmo loop que avança o turno / stream.
- Rode um reaper com eleição de líder; um decisor por ciclo.
- Faça
RUNNING → PENDINGcom UPDATE condicional (status + stale + attempt); respeiterowcount. - Defina teto de tentativas como única saída lateral para poison pill.
- Grave cancelamento no banco; workers e reaper leem a mesma flag.
- Mantenha serialização de turno + idempotência de inbound para absorver redelivery.
- 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.
