Workflows And Multi Agent Systems
State, Checkpointing & Durability
A 40-step agent run takes four minutes and costs
JrCodex·7 min read
Jr Codex Agentic AI Notes
Level: Advanced Prerequisites: Chapter 1: Workflow Patterns Time to complete: ~25 minutes
Table of Contents
- Why Durability Is the Threshold
- Designing the State Object
- Checkpointing
- Resuming Safely
- Time Travel and Forking
- Rollback and Compensation
- Summary & Next Steps
1. Why Durability Is the Threshold
The Arithmetic
─────────────────────────────────────────
A 40-step agent run takes four minutes and costs
real money.
Without durability, ANY of the following discards
all of it:
- a deploy
- a pod restart
- a transient network failure
- the user closing the tab
- a rate limit at step 38
With durability, each of those costs one step.
─────────────────────────────────────────
The Second Reason, Which Matters More
─────────────────────────────────────────
Human approval is not a pause of seconds. It is a
pause of HOURS — someone reads a Slack message
after lunch.
You cannot hold a process open for that. Human-in-
the-loop (Module 8, Chapter 2) is IMPOSSIBLE
without persisted state.
Durability is not an optimisation. It is what
makes an entire category of agent viable.
─────────────────────────────────────────
2. Designing the State Object
Everything needed to resume must be in the state. Nothing that matters may live only in local variables.
from dataclasses import dataclass, field, asdict
from typing import Literal
import json, time
@dataclass
class RunState:
run_id: str
goal: str
status: Literal["running", "waiting_approval", "done", "failed"] = "running"
messages: list = field(default_factory=list) # short-term memory (Module 3)
plan: dict | None = None # Module 4, Chapter 1
scratchpad: dict = field(default_factory=dict)
step: int = 0
cost_cents: float = 0.0
completed_tool_calls: dict = field(default_factory=dict) # call_id -> result
pending_approval: dict | None = None
updated_at: float = field(default_factory=time.time)
def to_json(self) -> str:
return json.dumps(asdict(self))
@classmethod
def from_json(cls, raw: str) -> "RunState":
return cls(**json.loads(raw))Three Rules for State Design
─────────────────────────────────────────
SERIALISABLE ONLY. No open connections, no file
handles, no client objects. If it cannot be JSON,
it cannot be checkpointed — reconstruct it on
resume instead.
RECORD COMPLETED SIDE EFFECTS. `completed_tool_
calls` is what prevents re-executing a refund
after a crash. This field is the difference
between safe and dangerous resumption.
INCLUDE THE BUDGET. Cost and step count must
survive a restart, or every crash silently resets
the bounds from Module 4, Chapter 4.
─────────────────────────────────────────
3. Checkpointing
class Checkpointer:
"""Persist after every step. The cost is a write; the benefit is the whole run."""
def __init__(self, db): self.db = db
def save(self, state: RunState) -> None:
state.updated_at = time.time()
self.db.execute(
"INSERT INTO checkpoints (run_id, step, state, at) VALUES (?,?,?,?)",
(state.run_id, state.step, state.to_json(), state.updated_at),
) # APPEND, never overwrite — Section 5
def latest(self, run_id: str) -> RunState | None:
row = self.db.query_one(
"SELECT state FROM checkpoints WHERE run_id=? ORDER BY step DESC LIMIT 1",
(run_id,))
return RunState.from_json(row["state"]) if row else None
def at_step(self, run_id: str, step: int) -> RunState | None:
row = self.db.query_one(
"SELECT state FROM checkpoints WHERE run_id=? AND step=?", (run_id, step))
return RunState.from_json(row["state"]) if row else NoneWhere to Checkpoint
─────────────────────────────────────────
AFTER every model call and BEFORE every tool
execution.
That ordering matters. The dangerous window is
between deciding to act and recording the result —
a crash there is where duplicate side effects come
from.
Checkpointing the DECISION before executing it
means resumption knows what was in flight.
─────────────────────────────────────────
4. Resuming Safely
Naive resumption re-runs the step that was in flight. For a read that is waste; for a refund it is a second refund.
def resume(run_id, checkpointer, agent, tools):
state = checkpointer.latest(run_id)
if state is None or state.status in ("done", "failed"):
return state
while state.step < MAX_STEPS:
reply = state.messages[-1] if pending(state) else agent.plan(state)
if not reply.tool_calls:
state.status = "done"
checkpointer.save(state)
return state
for call in reply.tool_calls:
if call.id in state.completed_tool_calls: # ALREADY DONE before the crash
result = state.completed_tool_calls[call.id] # replay the RECORDED result
else:
result = tools.execute(call)
state.completed_tool_calls[call.id] = result # record BEFORE continuing
checkpointer.save(state) # and checkpoint immediately
state.messages.append(tool_message(call.id, result))
state.step += 1
checkpointer.save(state)
return stateThe Two-Layer Protection
─────────────────────────────────────────
LAYER 1 completed_tool_calls, checked before
every execution. Handles the common case
cleanly.
LAYER 2 idempotency keys inside the tools
themselves (Module 2, Chapter 3).
Handles the crash that happens BETWEEN
executing and recording — the gap layer
1 cannot close.
You need both. Layer 1 alone still has a window;
layer 2 alone re-runs everything on every resume.
─────────────────────────────────────────
5. Time Travel and Forking
Because checkpoints are appended rather than overwritten, every prior state is still there.
def replay(run_id, checkpointer, upto=None):
"""Debug a non-deterministic run by walking its actual history."""
for step in range(0, upto or MAX_STEPS):
state = checkpointer.at_step(run_id, step)
if state is None:
break
print(f"step {state.step}: cost={state.cost_cents:.2f} "
f"last={summarise(state.messages[-1])}")
def fork(run_id, checkpointer, from_step, modification):
"""Branch from an earlier state with a change — A/B a fix without re-running."""
state = checkpointer.at_step(run_id, from_step)
state.run_id = f"{run_id}-fork-{from_step}"
modification(state) # e.g. edit the prompt, swap a tool result
checkpointer.save(state)
return state.run_idWhy This Is the Only Real Debugging Tool
─────────────────────────────────────────
An agent run is non-deterministic. Re-running it
produces a DIFFERENT trajectory, so you cannot
reproduce the bug you are chasing.
Checkpoint history is the actual sequence that
actually happened. Forking lets you change ONE
thing at step 12 and see what would have followed.
Without this you are reading logs and guessing.
Module 7, Chapter 4 builds on it.
─────────────────────────────────────────
6. Rollback and Compensation
State can be rolled back. The world cannot.
The Asymmetry
─────────────────────────────────────────
Restoring a checkpoint from step 8 undoes the
agent's memory of steps 9-12.
It does NOT un-send the email sent at step 10.
Anything with an external effect needs a
COMPENSATING ACTION, not a rollback.
─────────────────────────────────────────
@dataclass
class Effect:
tool: str
args: dict
result: dict
compensate: str | None # the tool that UNDOES this, if one exists
COMPENSATIONS = {
"issue_refund": "reverse_refund",
"create_ticket": "close_ticket",
"send_email": None, # NO compensation exists — this is the point
}
def rollback_to(state, checkpointer, step, tools):
"""Restore state, and compensate for effects that cannot be un-done by restoring."""
target = checkpointer.at_step(state.run_id, step)
for effect in reversed(effects_after(state, step)): # REVERSE order, like a stack
if effect.compensate:
tools.execute(effect.compensate, undo_args(effect))
else:
log.warning("uncompensatable effect: %s — human notification required",
effect.tool)
notify_operator(effect) # a person must handle it
return targetThe Design Consequence
─────────────────────────────────────────
Classify every tool by whether it is reversible
BEFORE you build the system.
REVERSIBLE compensate automatically
IRREVERSIBLE require human approval BEFORE
execution (Module 8, Chapter 2) —
because there is no "after"
An irreversible action taken without approval is
a permanent consequence of a probabilistic
decision. That is the situation the whole of
Module 8 exists to prevent.
─────────────────────────────────────────
7. Summary & Next Steps
Key Takeaways
- Durability turns any restart, deploy or rate limit from a lost run into a lost step, and it is what makes hours-long human approval possible at all.
- State must be fully serialisable, must record completed side effects, and must carry the cost and step budget across restarts.
- Checkpoint after the model call and before tool execution, and protect resumption with both a completed-calls record and idempotency keys inside the tools.
- Append-only checkpoints give replay and forking, which is the only practical way to debug a non-deterministic run — and rollback restores state but never un-does external effects.
Concept Check
- Why does the checkpoint need to happen before tool execution rather than only after?
- Why are
completed_tool_callsand tool-level idempotency keys both required rather than either alone? - An agent sent an email at step 10 and you roll back to step 8. What actually happens, and what must the system do?
Next Chapter
→ Chapter 3: Multi-Agent Architectures
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index