Engineering

Where the next run lives.

When a recurring task finishes, something has to decide when it runs again and write that down. It sounds like a detail. It is the decision every other part of a scheduler ends up arranged around, and we picked differently from most of the agents we get compared to.

We measured ourselves against these systems on recurring work already, and that is the benchmarks post. This one is not about who wins anything. It is about one design question that every system in this space has to answer, which is where you keep the answer to "when does this run again", and I think the differences between us come almost entirely out of that.

Hermes Agent keeps it on the job. Jobs live in a JSON file behind a file lock, each one carrying a next_run_at, and firing a job rewrites that field. Several machines would race over that, so a job also carries a fire_claim with a five minute TTL, and whoever stamps the claim first wins the fire. If that machine dies before it finishes, the claim goes stale once the TTL passes and another one can pick the job up. There is a separate SQLite ledger of execution attempts, and its own docstring is careful about what it is for: it records what happened, and it is explicitly not a retry queue. The schedule still lives on the job.

OpenClaw lands in nearly the same place. Jobs are a JSON file in the config directory, each carrying nextRunAtMs and lastRunAtMs, with a timer inside the process firing them. Their test files are the part I found familiar, because they are named after the things that evidently went wrong: catching up after a restart, and re-arming the timer while a job is still running. We have scars in both of those places.

I can only speak to the outside of Claude Code and Cowork, which is scheduled tasks, cron, and a loop that re-runs a prompt on an interval. I have not read how any of it works underneath, so I am not going to characterise it.

Three columns comparing where each system stores its next-run state. Hermes Agent: a jobs.json file behind a file lock, feeding a highlighted job row carrying next_run_at and a fire_claim with a five minute TTL, with a separate executions.db that records what happened and is explicitly not a retry queue. OpenClaw: a jobs.json file in the config dir, feeding a highlighted job row carrying nextRunAtMs and lastRunAtMs, with an in-process timer that one owner fires and that catches up after a restart. Unify: a Tasks definition holding authored intent plus enabled, feeding a highlighted Tasks/Executions box where one row per occurrence exists and the open row is what fires, with run_key derived from the occurrence so the same facts give the same string. A bottom band compares how two writers are kept from colliding in each: a lease held against a clock, a single process that owns the timer, and a key derived from facts.
The top two rows are the same picture in the first two columns and a different one in the third. The bottom row is what each design does instead of a lock.

Both of the designs I can see are good ones for what they are. A single process owns the timer, the state is a file you can open in an editor when something looks wrong, and backing it up means copying it. If I were writing an agent that runs on my own laptop I would do exactly this, and I would be finished the same afternoon.

We went somewhere else because our situation differs in one way that turned out to matter a lot: tasks belong to many separate tenants, they run on machines that appear and vanish, and there is already a Postgres underneath everything. So the definition row holds what a person actually asked for plus one switch for whether it is armed, and it holds no run state whatsoever. No next-run timestamp, no lease. Each occurrence is its own row, and the open row is the thing that fires.

What we get for that is two components arriving at the same occurrence without talking to each other, because the row's key is derived rather than allocated. It comes from the facts that identify the occurrence, down to a digest of the authored fields, so anything that agrees on those facts computes the same string, and create-or-adopt converges on one row instead of minting a second. Nobody holds a lock and nobody trusts a clock. A TTL, by contrast, is a guess about how long work takes, and a machine that dies mid-run wedges its job until that guess expires, so you end up choosing between a TTL long enough to be safe and one short enough not to hurt.

We got this wrong three times, which is the part I would want to read.

For a long time the runtime advanced the series itself. A dispatcher starting an occurrence would compute the next slot and write it, which felt natural, since the dispatcher is right there and already knows the task. Over about a month we found five different ways for that write not to happen, and every one of them ended a series in silence, because the run succeeded and the task simply never woke again. We deleted it in August 2026 and moved projection into the component that owns the repeat rule, so the successor gets written when a run is marked running, at the same instant as before but somewhere every dispatcher reaches.

Then the derived key has to be derived identically on both sides, and for a while it was not. One side normalised a destination like team:11 differently from the other, and an ISO timestamp carrying an offset did not match one without, so a dispatcher failed to recognise the row that had been projected for it and created its own. Two rows for one occurrence look exactly like two concurrent runs, so the overlap guard declined to start anything, and the series stopped. Silently, again.

And we added a sweep underneath all of it, a periodic pass that finds any armed task with no open occurrence and gives it one. It was scheduled every fifteen minutes, it ran on time, it answered 200 every single time, and for two days it repaired nothing at all, because one tenant hit a lock timeout and the whole pass shared one database transaction, so every tenant behind that one failed against a transaction that was already dead. From outside it was indistinguishable from a healthy fleet, given that a healthy fleet also repairs nothing.

The costs of our version are real and I do not want to skip them. There is a great deal more machinery here than a JSON file and a timer, the sweep still walks every project to find the handful that have tasks, and nobody can inspect the state by opening a file. I think the split earns its keep, though not by an enormous margin, and if we were single tenant I would not have built it this way.

If there is a lesson I would stand behind, it is not really about ledgers. All three of those failures were invariants that were true in the design and written down in comments and not true in production, and what hid every one of them was that failing looked exactly like succeeding. A dropped successor looks like a task nobody scheduled. A sweep that fixes nothing looks like a fleet with nothing to fix. We still have no good answer for the second one, and I wonder whether the honest fix is to make the healthy case say something out loud, so that silence stops being the normal state and starts being evidence.

Read next

The rest of the notes