DAY 58 · ENGINEERING

Durable Agent Execution

Replay Journal · Exactly-Once Side Effects · Determinism Boundary · Durable Suspend

2026-07-11 · BigCat

When your agent crashes, it shouldn't rerun everything from scratch — it should resume from the step where it died.

// WHY THIS MATTERS

Your agent runs for 8 minutes, makes 12 tool calls, burns tens of thousands of tokens — then step 13 gets OOM-killed. Now what? Most harnesses answer "start over from the top," including the steps that already succeeded, already charged a card, already sent an email. Fine in a demo, a disaster in production. Agent runtimes have grown from seconds to minutes and hours, and every step can hit a rate limit, a network blip, a worker restart. An "in-process while loop" simply can't survive that. The answer is durable execution: turn the agentic loop from an in-memory loop into a replayable log. This issue covers four things — how durable execution works, why a checkpoint snapshot still isn't enough, how to make side effects exactly-once, and how replay determinism reconciles with LLM non-determinism. It's the layer that upgrades Day 39 (error recovery) from "retry one step" to "keep the whole agent alive."

// 01

Durable Execution: Run the Agentic Loop as a Replayable Log

Claim: durable execution isn't "stronger retry" — it records the agent's entire execution history as a journal, and on crash it replays back to the failure point, rerunning none of the steps that already succeeded.

Background & Principle

An ordinary agent loop keeps its state in process memory: the messages array, the loop counter, local variables. The process dies, it's all gone. Durable execution engines (Temporal / Restate / DBOS) swap the model: every time your workflow function completes a "durable step," the engine writes that step's input and output into a journal (event log). After a crash, the engine reruns the same function on a fresh worker — but steps already in the journal aren't actually re-executed; their recorded return values are replayed back until execution catches up to the crash point, then continues.

This model is tailor-made for agents: LLM calls are slow and expensive (1–30s, real money), tool calls may have side effects, tasks span minutes. Temporal adds only about 10–50ms of overhead per durable step — negligible against an LLM call — in exchange for "kill the worker however you like, the agent resumes from the last successful step." DBOS is even lighter, storing the journal straight into your existing Postgres: one pip package, no separate broker or control plane.

Ordinary loop (in-memory) Durable Execution (journaled) ───────────────────────── ─────────────────────────────────── step1 ✓ (in RAM) step1 ✓ ─┐ step2 ✓ (in RAM) step2 ✓ ├─▶ JOURNAL (event log, durable) step3 ✓ (in RAM) step3 ✓ ─┘ │ step4 ✗ CRASH step4 ✗ CRASH │ ───────────────── ───────────────── ▼ restart ⇒ step1 from top restart ⇒ fresh worker replays fn: ❌ duplicate LLM / payment step1→replay (recorded value, no exec) ❌ spent tokens all wasted step2→replay step3→replay step4→actually executes ← resume here ✅ first 3 steps: zero rerun

Example

DBOS makes "which parts are durable steps" clearest — @DBOS.workflow is the replayable orchestration, @DBOS.step is a journaled atomic step:

from dbos import DBOS

@DBOS.step()                       # each step's return value is written to the Postgres journal
def call_llm(msgs): return client.messages.create(...)

@DBOS.step()
def run_tool(name, args): return TOOLS[name](args)

@DBOS.workflow()                   # on crash, resume from the last completed step
def agent(task):
    msgs = [{"role":"user","content":task}]
    for _ in range(30):
        r = call_llm(msgs)               # succeeded turns aren't re-sent on replay
        if r.stop_reason == "end_turn": return r
        out = run_tool(...)              # crash here → prior steps all skipped on restart
        msgs += [...]

You barely change the agent logic — you just wrap the "expensive / side-effecting" operations in a step and the loop in a workflow. The execution semantics go from "crash loses everything" to "crash resumes."

Failure mode: cramming everything into one giant step (e.g. the whole loop) — journal granularity too coarse, so a crash can only rewind to the loop start, i.e. no durability at all. Too fine (a step per string concat) blows up the journal and slows replay. Right granularity: one step per expensive or side-effecting boundary (one LLM call, one tool call).
More · Restate What is Durable Execution, restate.dev/what-is-durable-execution · DBOS Why DBOS, docs.dbos.dev/why-dbos · Temporal Durable Execution meets AI, temporal.io/blog
// 02

Checkpoint ≠ Durable Execution: Snapshots Can't Save a Half-Run Node

Claim: LangGraph's checkpointer stores state snapshots between nodes, not execution progress inside a node — so a node that crashes halfway reruns from its first line.

Background & Principle

Many assume "I use a LangGraph checkpointer / I persist state to a DB, therefore I'm durable." Not so. A checkpoint is a state snapshot saved after each super-step (node boundary); it gives you memory, human-in-the-loop, and node-level fault tolerance. But crash granularity is brutal: if a node internally calls an LLM, then sends an email, then crashes on the third line — on restart the entire node replays from line one: the LLM is re-called, the email re-sent. Diagrid's Checkpoints Are Not Durable Execution nails it: snapshots record "which cells you visited," not "which irreversible actions you took inside a cell."

LangGraph itself exposes three durability modes so you can pick the trade-off: exit (persist only on exit — fastest, no mid-run recovery), async (persist asynchronously — small chance of losing the last step), sync (persist synchronously before each next step — slowest but safest). Even sync only pushes recovery granularity to the node boundary; atomicity within a node is still on you. That's the fundamental gap versus step-level journal engines like Temporal/Restate.

Example

# ❌ Dangerous: one node holds two side-effecting actions
def node_book_trip(state):
    flight = charge_card(state)      # side effect 1: charge succeeds
    email  = send_confirm(flight)    # crash here → charge_card runs AGAIN on replay!
    return {"booking": email}

# ✅ Fix A: split into single-side-effect nodes; checkpoint boundary = side-effect boundary
graph.add_node("charge", charge_node)   # at most one irreversible action per node
graph.add_node("email",  email_node)
# compile with the safest mode, bind thread_id as the resume handle
app = graph.compile(checkpointer=saver, durability="sync")
app.invoke(inp, config={"configurable":{"thread_id":uid}})

The discipline: in a checkpoint engine, "one node = one safely-replayable unit." Slice nodes down to "at most one irreversible side effect" so the snapshot boundary actually aligns with the danger boundary. Otherwise, move up to a step-level journal engine (§03).

Failure mode: reaching for durability="exit" for convenience, then a long task crashes mid-run and the thread holds nothing — because it only persists on clean exit. Long-running / high-risk agents need at least async; anything touching charges/emails/external writes needs sync + single-side-effect nodes.
More · LangGraph Persistence docs, docs.langchain.com/.../persistence · Diagrid Checkpoints Are Not Durable Execution, diagrid.io/blog
// 03

Exactly-Once Side Effects: Journal Your LLM & Tool Calls, Then Add Idempotency

Claim: replay correctness rests on one iron rule — a recorded step replays data only, never re-executes; and the external world the engine can't reach (payment APIs, email) needs your own idempotency keys.

Background & Principle

Durable execution's "exactly-once" has an easily misread boundary: what the engine guarantees is that a step's return value is materialized in the journal exactly once — on replay it won't call your function body twice. Restate's ctx.run(), Temporal's activity, DBOS's @DBOS.step all mean this. But the engine can't stop the wrapped call itself from producing duplicate effects on the outside world — if your step charges twice on its first execution, the journal faithfully records that result.

So the real discipline is two layers: (1) wrap every external call as a step/activity so the engine handles "don't re-execute on replay"; (2) for non-idempotent external APIs, carry an idempotency key inside the step so the downstream service handles "dedupe duplicate requests." Restate even supports request-level idempotency keys with automatic dedup. LLM calls, though "externally side-effect-free," must still be wrapped as steps — otherwise replay re-burns tokens for nothing.

Example

# Idempotency key = a stable, reproducible ID within the workflow (never random/timestamp!)
@DBOS.step()
def charge(user, amount, idem_key):
    return stripe.PaymentIntent.create(
        amount=amount, customer=user,
        idempotency_key=idem_key)      # ← Stripe dedupes; replay/retry charges once

@DBOS.workflow()
def checkout(user, cart):
    # key derived from workflow identity → unchanged across replays → naturally idempotent
    key = f"pay-{DBOS.workflow_id}"
    charge(user, cart.total, key)      # crash+replay: step already recorded → not re-executed
    send_receipt(user)                 # if it crashes here, charge won't fire twice

The key thing is the key's origin: it must derive from the workflow's durable identity (workflow_id + step sequence), so every replay computes the same key. Using uuid4() or a timestamp as the key is the classic trap — each replay makes a new key, downstream can't tell it's the same transaction, dedup fails.

Failure mode: memoizing tool results while forgetting LLM outputs are non-deterministic — handling "read a file" (deterministic) and "ask the model" (non-deterministic) with the same memoization logic. Replaying a stale file read is fine; but if the business needs "always ask the latest model," you shouldn't blindly replay the cached value — mark that step recomputable. Deciding which steps replay recorded values vs. re-execute is a design-time call.
More · Restate Durable AI Loops, restate.dev/blog/durable-ai-loops · Restate AI examples (agents/A2A/MCP), github.com/restatedev/ai-examples
// 04

Replay Determinism vs LLM Non-Determinism: Push the Uncertainty into Activities

Claim: durable execution needs workflow code to be deterministically replayable, while LLMs, randomness, clocks, and networks are inherently uncertain — the fix is to squeeze all non-determinism out of the workflow body and into journaled steps.

Background & Principle

Replay works only if, when the workflow function reruns, its control flow follows the exact same path byte-for-byte — otherwise it replays into a branch that doesn't exist in the journal and the engine is lost. That forbids the workflow body from directly containing: random numbers, now()/clocks, direct network/file IO, and… direct LLM calls. All are "sources of non-determinism." This is a different determinism from Day 56's sampling determinism: that issue was "same prompt, same output"; here it's "same function reruns the same path."

The fix is uniform and elegant: wrap every source of non-determinism in a step/activity. On first execution it truly runs and its result enters the journal; on replay it doesn't run — it emits the old journaled value. So from the workflow's view, even a dice roll becomes deterministic. The LLM's non-deterministic output is "frozen" into the log, and the workflow body returns to a pure, replayable state machine. This also unlocks durable execution's killer capability: long suspend — with a durable timer + signal, an agent can "sleep" for days awaiting human approval, holding zero live process, and wake to resume the moment the signal arrives.

WORKFLOW body (must be deterministic, replayable) ┌───────────────────────────────────────────┐ │ plan → decide → loop → ... │ ← pure logic / control flow only │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────┐ ┌──────┐ ┌──────────────┐ │ │ │ STEP │ │ STEP │ │ durable timer│ │ │ │ LLM │ │ tool │ │ + signal (approve) │ │ └──┬───┘ └──┬───┘ └──────┬───────┘ │ └─────┼────────┼───────────┼─────────────────┘ ▼ ▼ ▼ [all non-determinism trapped in steps / frozen by journal] on replay: steps emit old values, timer fires instantly → body walks same path

Example

# Temporal style: workflow deterministic, non-determinism goes to activities
@workflow.defn
class ApprovalAgent:
    def __init__(self): self._approved = None
    @workflow.signal                       # external approval arrives via signal
    def approve(self, ok): self._approved = ok
    @workflow.run
    async def run(self, task):
        plan = await workflow.execute_activity(  # LLM call = activity
            call_llm, task, start_to_close_timeout=TIMEOUT)
        # sleep until "someone approves" or 3-day timeout — zero live process
        await workflow.wait_condition(
            lambda: self._approved is not None,
            timeout=timedelta(days=3))
        if self._approved:
            await workflow.execute_activity(apply_plan, plan, ...)

Note call_llm is never called directly in the workflow body — it's always an activity. wait_condition + signal lets the agent hang for days without burning a cent of compute — the cleanest way to land an "async human approval queue" (Day 34).

Failure mode: writing if random() > 0.5 or datetime.now() for branching in the workflow body — on replay the value changes, control flow diverges, and the engine throws a non-determinism error and crashes. Same for import requests firing directly in the workflow, or a version-drifting library driving a core branch. Iron rule: the workflow body touches pure logic only; all side effects and non-determinism sink into steps/activities.
More · Jack Vanlightly Demystifying Determinism in Durable Execution, jack-vanlightly.com · Temporal Of course you can build dynamic AI agents, temporal.io/blog

// PUTTING IT TOGETHER · Give Your Long-Running Agent a "Resume Point"

Turn this issue into one verifiable mini-project: take any agent loop you have, add durable execution, and kill the process by hand to prove it actually resumes.

  1. Pick an engine: already on Postgres → DBOS (lightest, pip install); cross-language / complex orchestration → Temporal; hugging your existing framework → Restate. Don't jump to the heaviest first.
  2. Draw step boundaries (§01): one step per LLM call, one per tool call; the loop body is the workflow. No giant steps.
  3. Split side-effect nodes (§02): any "charge / email / external write" action is its own step, and at most one irreversible action per step.
  4. Add idempotency keys (§03): every external write carries a key derived from workflow_id. Never uuid4()/timestamp.
  5. Purge non-determinism (§04): sink every random/now()/direct IO in the workflow body into a step.
  6. Chaos-verify: kill -9 the worker mid-run, restart, and confirm — (a) completed LLM calls weren't re-sent (check the token bill), (b) the charge happened once, (c) the agent resumed from the break, not the top. This is the only ground truth for the whole exercise: if you haven't killed it by hand, it isn't durable.

Do this and you'll feel something counterintuitive: durable execution barely changed your agent logic, yet turned "demo-grade reliability" into "production-grade reliability" — the difference lives entirely in the execution substrate, not the prompt.

// DEEPER QUESTIONS

Replay reuses old LLM outputs. But what if I actually want the agent, after a crash, to "rethink with the latest model" rather than replay the old answer?
This touches a design tension in durable execution: replay freezes history by default, yet sometimes you want "fresh." The fix is to explicitly distinguish step semantics. Most engines let you mark a call as "not journaled / recompute every time" (non-durable / local), or use versioned activities: old-journal steps keep old values, only newly triggered ones use the new model. More commonly, "don't swap brains inside one workflow" — a genuine re-think should be a new workflow instance, not a replay of the same one. Conflating "resume execution" and "re-decide" is a root cause of design accidents: the former wants consistency, the latter wants freshness — they shouldn't share a path.
Durable execution needs the workflow deterministic and replayable, yet the essence of an agent is letting the LLM dynamically decide the next step. Aren't these in conflict?
No, because "who is uncertain" is cleanly separated. The LLM's decision is indeed non-deterministic — but once that decision happens, its output is frozen into the journal by the activity, becoming a determined value. The workflow body just "walks if/else with that already-determined decision value," and the if/else itself is deterministic. In other words: non-determinism happens inside the activity boundary; the workflow always sees an already-collapsed result. Temporal's Of course you can build dynamic AI agents rebuts exactly the "durable = static DAG only" myth: dynamic agents run fine on durable engines, as long as all non-determinism sinks into activities.
When should you not adopt durable execution? Where's the cost?
Three cases to skip it: (1) the task is seconds-long, side-effect-free, safely retryable wholesale — a journal just adds complexity and a DB dependency; (2) the team hasn't even gotten the agent logic working — adopting Temporal too early makes debugging harder (extra worker/journal cognitive load); (3) the side effects are naturally idempotent or losslessly replayable (pure reads). The costs are mainly: per-step persistence overhead and latency, an engine/DB to operate, and the "workflow must be deterministic" coding constraint (no casual now()). The criterion, same as Day 03's workflow-vs-agent: use the simple thing until "crash loses progress" actually starts to hurt.
Between checkpoints (LangGraph) and step-level journals (Temporal), is there a middle ground? How to choose?
Yes, and it's common in practice to mix. The axis is "side-effect density × task duration × team ops capacity." A pure conversational / short RAG agent: LangGraph's checkpointer is enough, with built-in time-travel debugging and human-in-the-loop. Once you have cross-system writes, charges, multi-day suspend, strict exactly-once, move to a step-level journal engine. Middle ground: write agent logic in LangGraph, but run the whole thing as one activity inside Temporal (ZenML/AppScale have been promoting this combo) — LangGraph handles in-graph orchestration, Temporal handles cross-graph durability and retries. It needn't be either/or.
Idempotency keys must derive from workflow identity. But if an agent dynamically decides to "call the same tool 10 times," how do you keep each key both stable and mutually distinct?
The insight: the key binds not to "call content" but to "the call's position in the journal." The engine gives each step a deterministically incrementing id (step_id), reproduced identically on replay. So key = workflow_id + step_id (or + a stable loop index), naturally satisfying "same call's key unchanged on replay, different calls' keys distinct." Never key on call content — two intentionally-repeated identical calls (e.g. polling) would be wrongly deduped into one. That's why the key should be derived by the engine/framework, not hand-written: hand-rolling easily trades off "stable" against "unique."

// FURTHER READING