Você disparou um teste e o lembrete nunca chegou: execuções manuais do cron agora são um disparo extra

Você cria um lembrete para daqui a 30 minutos — tirar a panela do fogo — e quer ter certeza de que a coisa funciona de verdade. Então clica em Run no dashboard. Dispara na hora, a saída parece certa, você fecha a aba. Meia hora depois a cozinha cheira a metal queimado: o lembrete nunca chegou.
O scheduler não estava quebrado. O seu teste consumiu a única vaga do one-shot. O job se considerou concluído e se aposentou em silêncio.
Uma execução manual costumava contar como a execução
Dois campos de um registro de cron são fáceis de confundir: quando ele deve disparar em seguida (next_run_at) e quanto do orçamento já foi gasto (repeat.completed de repeat.times). Um one-shot é simplesmente um job com orçamento de um.
O código antigo tratava um disparo manual como execução normal: rodava e depois gravava a contabilidade — repeat.completed incrementado. Para um one-shot isso significava 1 de 1, então o registro era marcado completed, enabled=false, next_run_at=null. É um estado terminal. A execução agendada de verdade nunca teve chance. Jobs recorrentes perdiam execuções do mesmo jeito: repeat.times=3 te deixava com duas restantes depois de um único teste manual.
A nova regra: uma execução manual é um disparo extra
O comportamento agora é literal — um run-now não consome a ocorrência agendada e não mexe no orçamento. Relidos a partir do jobs.json, os mesmos dois jobs ficam assim:
| Job | Antes (depois de uma execução manual) | Agora |
|---|---|---|
lembrete one-shot in 30m |
completed, enabled=false, next_run_at=null, orçamento 1/1 — a execução agendada nunca dispara |
scheduled, enabled=true, next_run_at ainda no horário original, orçamento 0/1 |
every 60m, repeat.times=3 |
orçamento registrado como 1/3, next_run_at empurrado para “agora” |
orçamento 0/3, next_run_at inalterado |
Uma execução manual é hora extra: um turno a mais por cima, enquanto o turno agendado ainda aparece e o orçamento fica intocado.
O único caso em que ela é a execução agendada
Se a vaga do job já está vencida quando você clica em Run, essa execução é a ocorrência agendada — você não ganha duas execuções. As regras na prática:
- disparar antes de a vaga vencer → disparo extra, a vaga sobrevive;
- disparar depois de vencida → é a execução agendada, sem duplicata;
- a vaga vence enquanto a sua execução manual ainda roda → um one-shot dispara imediatamente depois, enquanto um job recorrente descarta a vaga vencida e assume a próxima ocorrência natural, exatamente como faria uma execução agendada.
Como usar
# dispara uma vez agora; a próxima execução agendada é preservada
hermes cron run <job_id>
# inspecione os jobs: next_run_at e os contadores de repeat ficam aqui
hermes cron list
# cria um one-shot
hermes cron create "in 30m" "Tire a panela do fogo antes de continuar codando"
O botão Run do dashboard, um agent chamando a tool de execução do cronjob_manage e uma execução encaminhada pelo gateway passam todos pelo mesmo caminho, então não existe divisão entre “seguro na CLI, destrutivo na UI”.
Por que isso é mais difícil do que parece
A dificuldade é um crash entre decidir e registrar. O claim acontece uma vez, sob o lock de jobs: quando o disparo é um run-now explícito enquanto a vaga guardada ainda está no futuro, o claim estampa essa vaga no registro do disparo e recoloca next_run_at na mesma escrita. Se o processo morre depois do claim mas antes da conclusão, o disco ainda aponta para o instante futuro correto, então nenhuma ocorrência se perde.
O caminho encaminhado pelo gateway (trigger_job) costumava sobrescrever next_run_at com “agora” e não guardava cópia; agora ele salva a vaga sobrescrita e a restaura no momento do claim, então disparos repetidos não conseguem desviar o cronograma.
A mudança também fechou uma lacuna de visibilidade: um one-shot cujo dispatch foi reivindicado mas cuja execução nunca terminou era removido sem saída, sem erro e sem registro. Agora ele grava um pequeno diagnóstico no diretório de output do job — dispatch reivindicado, execução nunca concluída, processo provavelmente morto — então o desaparecimento ao menos é explicável.
Dois detalhes que vale saber
- Uma pausa global não bloqueia execuções manuais. Enquanto
hermes pauseestá ativo nada agendado começa, mas um run now explícito é uma decisão do operador e ainda executa. É mais um motivo para ele não gastar o orçamento: recuperar o atraso à mão durante uma pausa não deve comer a execução real depois. - Uma execução manual não é “pular para a próxima”. Ela não avança o
next_run_at. Para pular uma ocorrência, usepause/resume, oueditno cronograma.
Para uma revisão de frota inteira, use o cron doctor. Cronogramas entre fusos horários estão em o artigo sobre timezone e DST, e identidade por profile mais escopo de secret em isolamento de secrets em disparos roteados. A lista de flags fica na página de referência do hermes cron.
Status da release
Entrou na main em 28 de outubro de 2026 (PR #146113). A release atual, v0.22.5 (23 de outubro de 2026), ainda tem a semântica antiga, então em um build mais velho um disparo de teste ainda pode aposentar um one-shot na hora.
Antes de confiar em um lembrete, você quer prova de que ele vai tocar. Essa premissa finalmente é verdadeira.