Checkpointing is not completion
Saving your agent's state is the easy half. The hard half is noticing it died, resuming it exactly once, and not re-running the side effects that already happened. Most frameworks ship the first and hand you the other three.
Your agent framework has checkpointing. You turned it on. You believe your long-running agent now survives a crash.
It probably does not, and the gap is not where people look for it.
Saving state is one of four things that have to be true. The other three are noticing the process died, resuming from the right point exactly once, and not repeating the side effects that already landed. A checkpointer gives you the first. The rest is yours, and most of the failure modes live there.
The problem: a saved state is a noun, not a verb
A checkpoint is a record of where you were. Resuming is an action somebody has to take.
That sounds like a distinction without a difference until you ask who takes it. Your process is gone. It is not going to notice its own death, read its own checkpoint, and start itself again. Something outside it has to observe the absence, decide the work is orphaned rather than slow, and re-enter at the right place.
If nothing in your stack does that, your checkpoint is a very good debugging artifact and nothing more.
Agitate: the four things, and where each one breaks
1. Saving the state
This is the part everyone ships, and it is genuinely the easy one. Serialize the graph state, write it to Postgres or Redis, move on.
Except that "write it" is two writes: the step's output, and the pointer that says which step you were on. If those two are not one atomic transaction, a crash between them leaves a checkpoint that disagrees with the writes it is supposed to describe.
That is not hypothetical. It is an open issue on LangGraph, filed by someone who had clearly been staring at it, and the failure description is worth quoting exactly:
State corruption — Writes from a partial superstep can be restored against a checkpoint from a different superstep
Silent inconsistency — The application doesn't fail, it just produces incorrect output derived from the corrupt state
Read the second one again. The run does not crash. It does not raise. It produces a confident, plausible, wrong answer derived from state that never existed at any single moment in time. That is the worst failure shape there is, because nothing pages you.
The issue notes that two PRs already landed to tighten the ordering, and that the remaining gap is the one that matters: put_writes and the checkpoint persistence do not share a transaction boundary. Partial failure and distributed crashes can still leave you inconsistent.
I want to be careful here. This is not a dunk. It is a hard problem, the maintainers are on it, and the fact that it is discussed this precisely in public is a credit to the project. The point is narrower: if the team that built the checkpointer is still closing the atomicity gap, then "we have checkpointing" is not the same claim as "our runs survive crashes."
2. Noticing it died
Say your state is perfectly durable. Your process still dies at 03:14.
Who notices?
Not the process. Not the checkpointer, which is a library that was loaded into the process that died. Something with a different lifetime has to be watching, has to have a definition of "this run should have made progress by now," and has to act on it.
The honest version of most setups is: a human notices, in the morning, because something downstream is missing. Or nobody notices, and the run is simply gone — state saved, beautifully, forever, resumed never.
This is the piece that cannot be a library. A library shares the fate of its host. Detection has to live somewhere that is still alive when your process is not.
3. Resuming exactly once
Now something has noticed and wants to resume. Two ways to get this wrong, and they are opposites.
Resume zero times. The detector fires, tries to re-enter, hits an error, and gives up quietly. The run stays dead. You now have a dead run that your dashboard believes is running.
Resume twice. The detector fires, starts a resume. The original process was not actually dead — it was blocked on a slow call, or the network partitioned and healed. Now two workers are executing the same run from the same checkpoint. Both of them will call the model. Both of them will call the tool.
The second one is worse and much harder to see, because both copies work. They just each do everything once, which adds up to twice.
Getting this right means leases with expiry, fencing so a resumed run invalidates its predecessor, and a resume path that is idempotent under concurrent entry. That is distributed-systems work, it is subtle, and it is not what you set out to build when you decided to write an agent.
4. Not repeating the side effects
Here is the one that costs money.
Your agent's step 4 issued a refund. Step 7 crashed. You resume — correctly, exactly once, from a perfectly consistent checkpoint at step 4.
Does the refund happen again?
Only if step 4's result was recorded, not just the fact that step 4 was reached. Those are different things, and a lot of checkpointing is the second kind: it records the position in the graph, and re-executes the node when you resume. For a pure function that is fine and even desirable. For the node that moved money, it is a second refund.
So the question to ask your framework is not "do you checkpoint." It is: on resume, does a completed step re-execute, or does it return its recorded result?
If the answer is "it re-executes," everything with a side effect needs its own idempotency key, its own dedupe table, and its own reconciliation for the case where the provider says it succeeded and you crashed before you recorded that. You are now writing the thing you were trying to avoid writing.
The bill
None of this shows up as an incident, which is why it stays unfixed.
- The corrupt-state resume produces a wrong answer, and wrong answers from an LLM look like the LLM being wrong. You will tune the prompt for a week.
- The never-resumed run is invisible by construction. It is not failing. It is absent.
- The double-resume shows up as a duplicate charge weeks later, in a support ticket, and gets logged as a payments bug.
- The re-run side effect shows up on the invoice.
Four different-looking problems, one root cause, and nothing in the stack that names it.
The solution: make completion the unit, not position
The fix is a change in what gets recorded.
Do not record where the run was. Record what has finished, and make the finished thing return its saved answer forever after.
That is what a durable step is. Each unit of work has a stable id. The first time it runs, its result is written down. Every pass after that — after a crash, a deploy, a resume, a replay — reaching that id returns the recorded result instead of executing. The work does not repeat because there is nothing to repeat: the answer is already known.
Once that is the primitive, the other three problems collapse into it rather than being separate projects:
Noticing is somebody else's job by construction, because the runtime holding your steps is not the process running your code. Your process dials out to it. So when your process dies, the party that notices is the one still standing, and the run gets picked up again: a runner can crash, restart, or be redeployed and the run resumes from its last checkpoint. No alerting rule, no operator, no ticket.
Resuming exactly once falls out of that same split. A run is owned by an app and executed by one of that app's live runners at a time, and every step that already completed returns its recorded result instead of executing. So the case that ruins you elsewhere - a second worker picking up a run and re-doing its work - has nothing left to re-do.
Side effects are the recorded result. The refund step returns { refundId } from its journal entry. It does not call the payment provider a second time, because it does not call anything a second time.
Here is the whole shape:
const triage = await ctx.step.run("triage", () => triageTicket(ticket));
await ctx.step.sleep("cool-off", "1h");
const refund = await ctx.step.run("refund", () => issueRefund(triage));Kill this process anywhere. During triage, during the hour of sleep, during refund. Start it again — same machine, different machine, after a deploy that changed the code around it. triage does not run again; it returns what it returned. The sleep does not restart; it resumes with the remaining time. And crucially, the run held no worker for that hour. It was not a thread parked on a timer. There was nothing to kill.
The sleep is the part that gives it away, incidentally. A framework that keeps a process alive to wait an hour has not solved durability; it has solved nothing and added an hour of exposure.
What this does not do
It does not make your agent correct. A step that returns a wrong answer will faithfully return that same wrong answer on every replay, forever. Determinism preserves your bugs with the same fidelity it preserves your successes.
It does not remove the need to think about which things are steps. Work outside a step runs on every pass. Put a model call, a database write, or Date.now() outside one and it will drift between passes in ways that are genuinely confusing to debug.
And it does not make a non-idempotent external API idempotent. If your payment provider will happily charge twice for two identical requests, the runtime's job is to only ever send one — which it does — but the provider's semantics are still the provider's.
The question to ask
Next time a framework tells you it has checkpointing, ask the four questions separately:
- Is the state write and the position write one transaction?
- What process notices that my run died, and what is its lifetime relative to mine?
- On resume, what stops two workers entering the same run?
- Does a completed step re-execute on resume, or return its recorded result?
"We have a checkpointer" is an answer to none of them.
We built Duraton so that all four have the same answer, because they are the same mechanism. Your agent's steps run once, the runtime outlives your process, and the run picks up exactly where it stopped.
This is the third in a series. The first was Your agent will die halfway. Restarting it is the easy part. and the second was Your agent will do something expensive. Where does it have to ask?