You Test-Fired It Once and the Reminder Never Came: Manual Cron Runs Are Now an Extra Fire


You set up a reminder for 30 minutes from now — take the pan off the heat — and you want to be sure the thing actually works. So you click Run in the dashboard. It fires immediately, the output looks right, you close the tab. Half an hour later the kitchen smells like burnt metal: the reminder never arrived.

The scheduler was not broken. Your test consumed the one-shot’s only slot. The job considered itself done and quietly retired.

A manual run used to count as the run

Two fields in a cron record are easy to confuse: when it should fire next (next_run_at), and how much of the budget is spent (repeat.completed out of repeat.times). A one-shot is simply a job with a budget of one.

The old code treated a manual trigger as a normal execution: it ran, then wrote the bookkeeping — repeat.completed incremented. For a one-shot that meant 1 of 1, so the record was stamped completed, enabled=false, next_run_at=null. That is a terminal state. The real scheduled run never had a chance. Recurring jobs lost runs the same way: repeat.times=3 gave you two remaining after a single manual test.

The new rule: a manual run is an extra fire

The behaviour is now literal — a run-now does not spend the scheduled occurrence and does not touch the budget. Read back from jobs.json, the same two jobs look like this:

Job Before (after one manual run) Now
in 30m one-shot reminder completed, enabled=false, next_run_at=null, budget 1/1 — the scheduled run never fires scheduled, enabled=true, next_run_at still the original time, budget 0/1
every 60m, repeat.times=3 budget recorded as 1/3, next_run_at pushed to “now” budget 0/3, next_run_at unchanged

A manual run is overtime: an extra shift on top, while the scheduled shift still shows up and the budget is untouched.

The one case where it is the scheduled run

If the job’s slot is already due when you hit Run, that run is the scheduled occurrence — you do not get two executions. The rules in practice:

  • trigger before the slot is due → extra fire, the slot survives;
  • trigger once it is due → that is the scheduled run, no duplicate;
  • the slot comes due while your manual run is still going → a one-shot fires immediately afterwards, while a recurring job drops the passed slot and takes its next natural occurrence, exactly as a scheduled run would.

How to use it

# fire once now; the next scheduled run is kept
hermes cron run <job_id>

# inspect jobs: next_run_at and the repeat counters live here
hermes cron list

# create a one-shot
hermes cron create "in 30m" "Take the pan off the heat before you keep coding"

The dashboard’s Run button, an agent calling the cronjob_manage run tool, and a gateway-forwarded run all go through the same path, so there is no split between “safe in the CLI, destructive in the UI”.

Why this is harder than it sounds

The difficulty is a crash between deciding and recording. The claim happens once, under the jobs lock: when the fire is an explicit run-now while the stored slot is still in the future, the claim stamps that slot on the fire record and puts next_run_at back in the same write. If the process dies after the claim but before completion, disk still points at the correct future instant, so no occurrence is lost.

The gateway-forwarded path (trigger_job) used to overwrite next_run_at with “now” and keep no copy; it now saves the overwritten slot and restores it at claim time, so repeated triggers cannot drift the schedule.

The change also fixed a visibility gap: a one-shot whose dispatch was claimed but whose run never completed used to be removed with no output, no error and no record. It now writes a small diagnostic into the job’s output directory — dispatch claimed, run never completed, process most likely killed — so the disappearance is at least explainable.

Two details worth knowing

  • A global pause does not block manual runs. While hermes pause is engaged nothing scheduled starts, but an explicit run now is an operator override and still executes. That is another reason it must not spend the budget: catching up by hand during a pause should not eat the real run afterwards.
  • A manual run is not “skip to next”. It does not advance next_run_at. To skip an occurrence, pause / resume it, or edit the schedule.

For a whole-fleet checkup, use cron doctor. Cross-timezone schedules are covered in the timezone and DST write-up, and per-profile identity plus secret scope in routed-fire secret isolation. The flag list is on the hermes cron reference page.

Release status

Merged into main on 28 October 2026 (PR #146113). The current release, v0.22.5 (23 October 2026), still has the old semantics, so on an older build a test-fire can still retire a one-shot on the spot.

Before you trust a reminder, you want proof that it will still ring. That premise is finally true.