Une seule ligne parasite dans jobs.json a stoppé tous vos cron jobs


C’est lundi matin et le briefing quotidien n’est jamais arrivé. Vous vérifiez mercredi — vide. Mardi — vide aussi. hermes cron list affiche toujours chaque job, next_run_at semble raisonnable, et aucune ligne de la sortie de statut ne vous crie dessus. Rien ne s’exécute pour autant. Quand tous les jobs se taisent d’un coup, la cause n’est généralement pas vos définitions de jobs : c’est un seul null qui s’est glissé dans ~/.hermes/cron/jobs.json. Une seule entrée corrompue suffit à arrêter tout le planificateur. Un correctif fusionné le 2026-09-26 (PR #124034) prend en charge cette classe d’entrées ; voici comment la panne se produit, ce qui se passe désormais, et comment vérifier votre propre machine.

Comment un simple null fait tomber le planificateur

jobs.json est le store des cron jobs. À chaque tick, le planificateur appelle load_jobs() pour le lire. Si cette lecture lève une exception, le tick échoue — et le tick suivant relit le même fichier et échoue à nouveau. Les jobs eux-mêmes sont parfaitement sains ; ils n’obtiennent simplement jamais leur tour.

Trois entrées distinctes pouvaient chacune produire ce résultat :

Entrée Ancien comportement Conséquence
Une entrée non-objet dans la liste jobs (null, une chaîne, un nombre) load_jobs() levait une exception Chaque tick échouait, donc aucun job ne s’exécutait (issue #123281)
Un champ jobs scalaire, par ex. {"jobs": null} Renvoyé tel quel à l’appelant Les appelants explosaient ailleurs
Un job dont le repeat.completed valait null mark_job_run / update_job levait TypeError: NoneType + int L’enregistrement d’une exécution plantait (issue #122114)

Le troisième cas portait un bug d’affichage qui le rendait facile à ignorer : avec un compteur cassé, l’outil cronjob affichait un job à exécution unique comme 1/1, et hermes cron affichait le compteur comme None/3. Un affichage bizarre, pas un affichage d’erreur — alors le problème restait en place.

Ce qui se passe désormais

  • Les entrées invalides sont ignorées et le fichier est réparé. Le chargeur ne lève plus d’exception à cause d’un seul élément pourri ; il écarte les entrées non-objets et réécrit le store nettoyé sur le disque — y compris lorsqu’il ne reste aucun job valide, auquel cas le fichier devient [].
  • Un champ jobs scalaire est normalisé. {"jobs": null} devient [] et est enregistré, de sorte que chaque appelant obtient une liste.
  • Un compteur non entier est normalisé. repeat.completed est réinitialisé à une valeur valide lorsqu’il n’est pas un entier positif ou nul (0 quand il est illisible), une seule fois, au chargement — ainsi chaque lecteur voit un entier et les erreurs d’affichage 1/1 / None/3 disparaissent avec lui.
  • La réparation s’exécute sous le verrou. L’enregistrement relit le fichier après avoir acquis le verrou du job store, pour qu’un update_job concurrent ne soit pas annulé. C’était le point de revue bloquant du patch d’origine, et c’est la différence entre « réparer » et « annuler silencieusement la modification de quelqu’un ».
  • Les avertissements journalisent les types, pas les contenus. Chaque avertissement de réparation est émis une fois, et il nomme le type de la valeur écartée plutôt que la valeur elle-même.

La documentation l’énonce désormais clairement : si une édition manuelle laisse jobs.json malformé, le planificateur le répare au chargement suivant au lieu de s’arrêter.

Vérifier votre propre machine

Commencez par confirmer que le planificateur et les jobs eux-mêmes sont sains :

hermes cron list        # jobs and their status
hermes cron doctor      # failed runs, failed deliveries, overdue next_run_at, missing scripts

Pour chercher les valeurs qui ne devraient pas être là, cette passe en lecture seule rapporte les formes rencontrées :

python3 - <<'PY'
import json, os
p = os.path.expanduser("~/.hermes/cron/jobs.json")
d = json.load(open(p))
jobs = d.get("jobs")
print("jobs type:", type(jobs).__name__)
if isinstance(jobs, list):
    bad = [(i, type(j).__name__) for i, j in enumerate(jobs) if not isinstance(j, dict)]
    print("entries:", len(jobs), "| non-object entries:", bad or "none")
    for j in jobs:
        c = (j.get("repeat") or {}).get("completed")
        if not isinstance(c, int) or c < 0:
            print("  bad counter:", j.get("name") or j.get("id"), "->", repr(c))
PY

Il n’écrit jamais. La réparation elle-même est le travail de Hermes : mettez à jour, laissez le planificateur faire un tick, et les entrées invalides ont disparu.

Ne modifiez pas jobs.json à la main

La documentation est explicite : gérez les jobs via l’outil cronjob_manage, hermes cron edit ou /cron — pas en modifiant jobs.json directement. Les éditions directes peuvent échouer silencieusement lorsque le chemin est bloqué par la protection d’écriture de fichiers (par exemple quand HERMES_WRITE_SAFE_ROOT est défini), et le pied de page du vérificateur de mutation de fichier est le seul signal faisant autorité indiquant que rien n’a été enregistré.

C’est exactement pourquoi ce correctif compte : les accidents qui surviennent en dehors du chemin des outils — un script externe, un outil de synchronisation, une édition manuelle inachevée — ne devraient pas vous coûter tous les jobs planifiés de la machine.

Ce qui reste ouvert

La PR est explicite sur une chose qu’elle n’a délibérément pas faite : l’issue #123120 propose d’échouer de façon fermée sur un store corrompu plutôt que de le réparer, ce qui constitue une autre posture comportementale et une décision de mainteneur. Elle reste ouverte. La politique actuelle, c’est d’abord l’auto-réparation.

À retenir

jobs.json est passé de « une entrée parasite tue la flotte » à « on l’écarte, on répare le fichier, on continue à tirer », et repeat.completed est passé du crash à un entier garanti. Si vous faites tourner du travail planifié sans surveillance, cela vaut la peine d’adopter ce correctif tôt — il corrige le mode de défaillance le plus difficile à diagnostiquer, celui où tout semble configuré et où rien ne se produit. Pour le contexte : une seule commande qui donne un bilan complet à toute la flotte, les pièges de fuseau horaire et d’heure d’été dans les plannings qui traversent minuit, des cron jobs qui gardent des notes entre les exécutions, et la référence de la commande hermes cron.