Durable Execution for AI Agents: Surviving Hours-Long Runs

Durable execution writes every completed step of a run to a journal, so a restart replays that journal and skips the work that already finished. A job queue's unit of retry is the whole job, so a failure on step nine re-runs steps one to eight from scratch. The difference is the granularity of recovery, not the reliability of the storage.
Every LLM call and paid tool call in an agent run is billed when it happens, not when the run finishes. If the run crashes at step nine and the retry restarts at step one, you pay for the eight calls that already succeeded a second time. Durable execution avoids that by returning the recorded output of each completed step instead of calling the model again.
No. A single agent call that returns in a few seconds has nothing worth resuming, and an idempotency key plus a steps table in Postgres is enough for a short pipeline. Reach for a workflow platform when runs are long enough to be interrupted, expensive enough that repeating them hurts, or paused waiting on something outside your process. Temporal is a service you have to operate, which is a real cost for a small team.
Workflow code has to be deterministic because the runtime executes it repeatedly during replay, and Temporal's docs require that it makes the same API calls in the same sequence given the same input. A model can return a different answer to the same prompt, so it can never satisfy that rule. Putting the call in an activity or a ctx.run step means the result is journalled once and replayed thereafter.
Durable runtimes persist the wait itself. Restate's durable sleep is a journal entry owned by the server, so a handler that slept eight of twelve hours before crashing sleeps only the remaining four. For a human decision you use an awakeable or a named signal: the runtime suspends the invocation, and an external system resolves the token whenever the person actually acts.

Key Takeaway
Durable execution records every completed step of a long-running AI agent to a journal, so a crash resumes at the failed step instead of restarting the run. A job queue retries the whole job and re-pays for every LLM call already made. Temporal, Restate, DBOS and Inngest all sell that one guarantee.
The escalation scheduler in hierarchical-approval exists because a purchase order can sit at level two for three days while the finance director is on leave. I gave the engine an injectable Clock and a ManualClock for tests precisely so a process measured in days could be verified in milliseconds. The same shape turns up the moment an agent runs long enough to fail partway. An agent that dies on its ninth tool call and is restarted from the first by an ordinary job queue does not merely lose time: it re-pays for the eight model calls that already succeeded. That is what a retry means once the work is metered by the token.
That second failure is not a queue bug. It is a granularity mismatch: a queue's unit of retry is the job, and the job was the entire agent run. Durable execution moves the unit of retry down to the step. This post is what I learned comparing four runtimes that do it, what the replay model demands of your code, and the cases where none of it is worth the operational cost.
The finding first: a job queue and a durable runtime both survive a crash, but they resume in different places. BullMQ's contract is that a failed job runs again, the whole handler, from its first line. Restate's contract is that the handler runs again and every step that already completed returns its recorded result instead of executing. Restate's own documentation puts it plainly: it replays the journal, skipping completed steps and resuming from exactly where it left off. What gets written to that journal is the whole trick.
That is why the difference is a money question rather than a taste question. For a CRUD job, re-running from the top costs a few hundred milliseconds. For an agent that has already made eight model calls, fetched three PDFs and written a row, re-running from the top costs the eight calls again, plus a duplicate row for any step that was not idempotent.

Here is the shape of the mistake. The naive version is one queue job wrapping the whole loop, and it is the version almost everyone writes first, because it is the version that works on the happy path and reads perfectly well in review.
// Wrong: one BullMQ job for the entire agent run.
// A throw on the ninth tool call re-queues the job, and attempt 2 restarts
// at line 1 — the eight model calls before the crash are billed again.
worker.process("tender-review", async (job) => {
const plan = await llm.plan(job.data.brief); // paid
const notes = [];
for (const url of plan.sources) {
notes.push(await summarisePage(url)); // paid, once per source
}
return llm.compose(notes); // paid
});
// Right: every paid call is its own journalled step.
// Attempt 2 still re-enters the handler from the top, but each completed
// ctx.run returns its recorded result instead of calling the model again.
export const tenderReview = restate.workflow({
name: "TenderReview",
handlers: {
run: async (ctx: restate.WorkflowContext, brief: string) => {
const plan = await ctx.run("plan", () => llm.plan(brief));
const notes = [];
for (const url of plan.sources) {
// One journal entry per source, so a resume lands on the source
// that actually failed rather than on the first one.
notes.push(await ctx.run("page:" + url, () => summarisePage(url)));
}
return ctx.run("compose", () => llm.compose(notes));
},
},
});The durable version is not more code, it is differently bounded code. Each paid call sits inside its own ctx.run. On the second attempt the handler still runs from the top, that part is unchanged, but the plan step and the seven page summaries return from the journal without touching the model, and execution genuinely continues at page eight. Inngest describes the identical mechanism in different words: the function is re-executed with the state of the previous execution, and for a step that already succeeded the SDK injects the result into the return value instead of running the code.
Replay only works if the code around the steps makes the same decisions in the same order every time. Temporal states the constraint directly: any time your workflow code is executed it must make the same API calls in the same sequence given the same input, and non-deterministic operations such as API calls, LLM invocations and database queries belong in Activities. People bounce off this rule because it looks arbitrary until you internalise the asymmetry: the orchestration path is executed many times, the steps are executed once.
// Wrong: the orchestration path branches on values that change on replay.
if (Date.now() - startedAt > 3_600_000) return giveUp(); // replay disagrees
const shard = Math.floor(Math.random() * 4); // replay disagrees
const fx = await fetch(RATES_URL).then((r) => r.json()); // replay re-calls
// Right: replay-stable equivalents, or push the call into a step.
const now = await ctx.date.now(); // millis, consistent across retries
const shard = Math.floor(ctx.rand.random() * 4); // seeded by the invocation id
const fx = await ctx.run("fx-rate", () =>
fetch(RATES_URL).then((r) => r.json()),
);
await ctx.sleep({ seconds: 3600 }); // a durable timer, not setTimeout:
// the process may not exist in an hour
// An LLM call can return a different answer to the same prompt, so it can
// never sit in the deterministic path. It is a step by construction.
const answer = await ctx.run("classify", () => llm.classify(doc));Notice what that makes a model call. An LLM can return a different answer to the same prompt, so it can never live in the deterministic path; it is a step by construction. That is convenient rather than restrictive. The thing you most want to avoid re-running is exactly the thing the runtime forces you to isolate, which means a correctly written durable agent gets cost recovery as a side effect of satisfying the determinism rule.

Use the runtime's seeded random for idempotency keys, not the platform UUID function. Restate's ctx.rand.uuidv4 is seeded by the invocation ID, so a retry sends the vendor the same key it saw the first time. Generate that key with crypto.randomUUID instead and every replay looks like a brand-new request to the payment or model API you were trying to deduplicate against.
The interesting difference between these products is not the API. They converge on the same idea of wrapping the side effect. The difference is what you have to run and where the journal lives, and that is what decides whether adopting one is an afternoon or a quarter.
| Runtime | Where execution state lives | What you deploy | How a run resumes | SDKs |
|---|---|---|---|---|
| Temporal | Event History, held by the Temporal Service | A Temporal Service plus your own Worker processes | A Worker replays the code and matches each Command against Events already in the history | Go, Java, TypeScript, Python, .NET, PHP, Ruby |
| Restate | A journal plus an embedded key-value store inside the Restate Server | The Restate Server; your handlers stay ordinary HTTP services | Restate re-invokes the handler and replays the journal, skipping completed steps | TypeScript, Java, Kotlin, Go, Python, Rust |
| DBOS | Checkpoints in a Postgres system database you already own | Nothing beyond Postgres; the library runs in your process | The app finds workflows left pending and re-runs them, returning checkpointed step outputs | Python, TypeScript, Go, Java |
| Inngest | Memoised step state in Inngest's managed function state store | An HTTP endpoint served by your app; Inngest orchestrates from its side | Your function is re-invoked with previous state and the SDK injects memoised step results | TypeScript, Python, Go |
DBOS is the outlier worth noticing if you already run Postgres. Its architecture page is explicit that there is no separate orchestration server and no infrastructure required besides Postgres, because the library checkpoints workflows and steps into a system database inside your own cluster. Temporal is the opposite trade: a service to operate, in exchange for the deepest story on visibility, versioning and polyglot workers. Restate sits between them, with the server as the only new process and handlers that remain plain HTTP services.
This is where the ERP work and the agent work turned out to be one problem. In hierarchical-approval an instance legitimately sits pending until a person acts, slaDeadlineDays defines when the escalation scheduler should care, and the engine accepts a Clock so those days can be simulated. A test advances a ManualClock by three days, ticks the scheduler, and the SLA breach event fires with zero wall-clock time elapsed. I built that because there was no other way to test a three-day deadline in CI.
// hierarchical-approval: the engine takes a Clock, so "three days" is testable.
import { ApprovalTestKit } from "hierarchical-approval/testing";
const { engine, clock } = ApprovalTestKit.create(); // MemoryAdapter + ManualClock
await engine.defineTemplate({
name: "purchase-order",
documentType: "purchase_order",
levels: [{ level: 1, name: "Finance", approvers: [finance], mode: "any" }],
slaDeadlineDays: 2,
});
clock.advanceDays(3); // no real timers, no three-day test run
await engine["escalation"].tick(); // 'approval:sla_breached' fires here
// A durable runtime hands you the same two primitives, first-class:
const { id, promise } = ctx.awakeable(); // one-shot token, like an SLA row
await ctx.run(() => emailApprover(instanceId, id));
const decision = await promise; // suspends the invocation; survives a restart
// Or a named signal, which can be resolved more than once — the same channel
// works for "approved" and for steering an agent mid-run.
const steer = await ctx.signal("steer");A durable runtime gives you those two primitives as language-level constructs instead of as something you build. A durable sleep is a journal entry the server owns, so it outlives the process that started it. An awakeable hands an external system a one-shot token to resolve, and Restate's own table lists agent steering and human approvals as precisely what signals are for. An agent pausing for sign-off and an approval pausing for a director are the same shape; I had written a narrow, domain-specific version of the general thing without knowing there was a general thing.
hierarchical-approval is my own npm package, a TypeScript-first hierarchical approval engine for ERP developers, MIT licensed and currently at 4.0.0. It is not built on a durable execution runtime: it persists through its own storage adapters and runs its own escalation scheduler. That is the honest comparison, the same requirements solved narrowly for one domain rather than generally. hierarchical-approval
Three of these surprised me, and all three are documented by the vendors themselves rather than discovered in anyone's incident review. None of them is a reason to avoid durable execution, but each one is a reason to read the runtime's docs before the agent is in production rather than after.
The journal is a real artefact with a size, a schema and a compatibility surface, and it now sits between your deploy pipeline and your running agents. Treat it the way you treat a database migration, not the way you treat a log file.
The dangerous failure is not a crash, it is a wrong resume. Ship a changed orchestration path while runs are in flight and a replay can reach a decision point that no longer exists in the code, so the run either trips a non-determinism check or silently takes a branch nobody intended. Version workflows deliberately and keep old versions serving until their runs drain; orchestration code is not application code you can hotfix.
The unfashionable part, stated plainly: most agent calls should never go near a workflow engine. A single tool call that returns in five seconds has nothing to resume. If it fails, run it again and you have lost five seconds and one model call. Durable execution earns its cost only when a run is long enough to be interrupted, expensive enough that repeating it hurts, or paused on something outside your own process. Here is the ladder I now climb, and I stop at the first rung that holds.
For a two-person shop, that fourth rung is a real bill paid in attention: a service to run, a versioning discipline to hold, and a new class of failure to learn to read. I would not pay it for one agent. I would pay it for a fleet, and the honest signal that you have a fleet is when two different teams have independently written their own steps table.
The rule I carry out of this is about granularity, not about products. Ask what your unit of retry is. If the answer is the whole job, every failure charges you again for the work that already succeeded, and with agents that work is metered by the token. Make the step the unit, with a journal, a steps table, or whatever matches the size of the problem, and a crash stops being an invoice.
Sources and further reading