Why Durable Execution Belongs Below the Spring AI Framework
Agent frameworks make three different persistence promises. Learn why only a runtime below the framework can detect failure and finish a Spring AI agent's run.
Javier Aliaga
Senior Software Engineer
The integration announcement post showed the crash and the recovery. This one covers why that recovery has to come from a runtime underneath the framework, starting with what agent frameworks actually mean when they say persistence.
"Persistence" is three different promises
Agent frameworks all say something about persistence. Read the fine print, and the word covers three promises of very different strength.
Promise one: your conversation survives. This is chat memory: Spring AI's ChatMemory, or the session stores other frameworks keep in a database. The message history outlives the process, so the next turn can see what was said. It does nothing for the turn that was executing when the process died. That run is gone, along with any half-completed tool work. Conversation persistence answers whether the agent will remember. It says nothing about whether the work will finish.
Promise two: your framework can checkpoint itself. Some frameworks, in Java and in Python, can snapshot their own state machine after each step and store the snapshot in a file or a database. When the agent runs again, it resumes from the latest snapshot instead of starting over. The progress survives, which yields some durability, but not enough.
The promise has limits. The snapshot format belongs to the framework, so it protects agents written in that framework and nothing else. And a snapshot only helps once something starts the agent again. Nothing notices that the process died. No component picks a healthy replica to resume on, and if two replicas resume the same snapshot at once, nothing stops them.
Promise three: the runtime detects the failure and finishes the job. This is durable execution. Each model call and each tool call is recorded as it completes, and the record lives outside the process. Promise two plus better checkpoints would not get you here. The difference is a runtime that watches the execution: it detects that the worker died, re-schedules the run on a healthy one, guarantees only one worker owns the resumed run, replays the completed steps from the record, and continues from the first unfinished one. Completed side effects are never re-executed. Failure detection is the difficult one. Telling a dead process from a slow one, and restarting its work exactly once, is a distributed-systems problem that no library can solve from inside the process it is trying to protect.
The standalone agent frameworks do not ship this today, in Java or in Python. Where it exists, it comes from a runtime underneath: Dapr Agents runs every agent as a Dapr Workflow, and that is the design this series brings to the Spring ecosystem. Spring AI's own agentic patterns guidance composes the loop in plain Java, with no durability primitive, because durability sits in a different layer.
Failure detection is the hard problem
We made the general argument in Durable Execution: The Missing Runtime Primitive for Agents. The short version for Java readers: from inside a distributed system, you cannot reliably tell a dead process from a slow one. A worker that stopped answering may have crashed, or it may be stuck in a long garbage-collection pause, about to continue as if nothing happened.
That ambiguity makes recovery hard. Restart the work too eagerly and two workers end up committing the same booking. Wait long enough to be safe and the run sits dead while a user watches a spinner. And whatever makes the call must also make it stick. If the old worker wakes up after its run was handed to a replacement, something has to stop it from carrying on as if it still owned the work. A correct answer needs three things working together. A heartbeat that notices the silence. An arbiter outside the failing process that declares the death, once. And a rule that makes the decision final, so a worker declared dead cannot come back and keep writing.
None of that can live in your application. A process cannot report its own death, and any watchdog you build inside it dies with it. Detection has to live in a runtime that stands outside the process, one that tracks every run, notices the silence, decides once, hands the run to a healthy worker, and replays the record of completed steps so nothing finished is done twice. The record matters, but the record alone is promise two. Detection and single-owner recovery are what turn a checkpoint into durable execution.
A framework checkpoint, however well built, is written by the process it is trying to protect and consumed by code you write. It can shrink the damage from a crash, but the questions that decide correctness stay open: who notices the crash, who restarts the run, what happens when two replicas race to resume, whether a completed booking is re-run by an impatient retry. Durable execution moves that whole class of problems into infrastructure, the same move that took transactions out of application code and put them into databases.
For Spring AI specifically, the layering is not even a choice. With no agent abstraction in the framework, there is no framework object whose state a checkpointer could own. The only stable seam Spring AI exposes is the ChatClient call itself, so whatever durability a Spring AI agent gets has to come from underneath that seam. Spring AI is not being criticized here. The division of labor is working as designed.
There is also a strategic reason to want durability below the framework rather than inside it. New frameworks keep appearing, with major vendors behind them. Betting your reliability story on any single framework's built-in checkpointing means re-solving durability if you ever switch. A runtime layer under the framework is portable across that churn. Whichever agent abstraction wins, the record of what ran survives.
What this looks like in practice
That seam is where the fix attaches. The diagrid-spring-ai library intercepts the ChatClient call and runs the agent loop as a workflow on Dapr, the open source runtime that provides durable execution as a building block to agents. Every model call and every tool call becomes a recorded step. Your agent code does not change. The event planner above stays as it is.
The runtime half is the part you cannot build inside your application, and it is the part Catalyst provides as a service. Catalyst runs the Dapr Workflows integration for you: it keeps the execution record, watches the workers, detects the failure, and resumes the run on a healthy worker, replaying the completed steps so nothing finished runs twice. All the machinery from the previous section, the heartbeat, the arbiter, the single-owner rule, comes with the project rather than with an ops team. The same project provides the agent infrastructure the rest of this series builds on, agent discovery, durable memory, and tracing.
Execution identity: attach, don't dedup
The event-planner's tools have no side effects, so re-running the interrupted step is harmless. The moment a tool has a real side effect, a booking or a payment, the second failure from the announcement post returns in a new form: a client retry of the same request would start a second run and redo the work. Execution identity handles this.
By default, every durable call runs under a new random instance id, so a retried call is a new execution. That is the right default for a call you would not mind running twice, like answering a question or summarizing a document. When the call books, charges, or writes something, supply your own ID and it becomes an attach handle instead.
chatClient.prompt().user(msg)
.advisors(a -> a.param(DurableAdvisor.INSTANCE_ID_KEY, myId))
.call().content();The ID has to identify the request, not the attempt. Derive it from something the caller already holds that survives a retry, an order number or a booking reference, and every retry of that request finds its way back to the same run. Generate a fresh UUID on each attempt and you are back to the default behavior with extra steps.
A repeated call with the same ID then gets database semantics, a lookup by key rather than another insert. Same key, same run:
| The instance is... | The repeated call... |
|---|---|
| running | waits for it and returns its result |
| completed | returns the recorded answer, no re-run |
| failed | surfaces the recorded failure, no re-run |
| absent | runs fresh |
Keep the ID, repeat the call, loop until it returns. The value is in what it lets you leave out. There is no reconnect API to learn for the recovery path and no dedup table of your own to keep. A timeout, a client crash, and a worker crash all get the same handling, because in every case the client cannot know how far the previous attempt got, and with an ID it does not need to.
You can see this in the crash-recovery quickstart example. A booking agent schedules under an ID (trip-42) and the app is killed mid-booking. After a restart the call with the same ID attaches to the resumed run instead of starting a new instance. The confirmation code it returns is derived from the booking reference, so getting an identical code back is visible proof the booking was not redone.
An ID is a bearer handle. Whoever presents it receives whatever that run returned. If trip-42 were taken straight from a request parameter, a second caller passing trip-42 would be handed the first caller's itinerary. Treat these IDs the way you treat primary keys in a shared table, and namespace them with something the caller does not choose, such as the tenant or the authenticated user. An ID also stays spent once its run finishes, so it keeps returning that recorded answer until the instance is purged and cannot be recycled for new work. Both points are covered in the Spring AI integration docs.
"Exactly-once" is a phrase that gets abused, so the requirements on replay and idempotency need stating precisely.
A completed step never re-executes. Once an LLM call or a tool call finishes and its result is checkpointed, a crash-and-resume replays the result from history, not the call. This is the core guarantee, and it is what prevents the double booking.
Tools need to be idempotent, because a step interrupted mid-flight runs again. If the process dies while a tool is executing, as in the event-planner demo from the announcement post, the resumed workflow re-runs that step from the start. The execution guarantee for any single step is at-least-once, so a tool with real side effects should be idempotent on a business key in its arguments: a booking reference, or a caller-supplied idempotency token. The crash-recovery quickstart's confirmation code, derived from the booking reference, is that pattern. That responsibility is with the tool author. The Dapr Workflow docs cover the underlying model.
The next post picks up where one durable call stops being enough: chat memory that survives a restart, agent discovery, and a single trace that covers every LLM call and tool call in a run.
The last post goes into the design decisions behind all of it, including how a tool is made crash-safe and what the runtime records on every step.
Try Catalyst
Connect your favorite Spring AI app today to Catalyst for free and experience agentic durable execution. Try the built-in quickstart or run the event-planner quickstart. Kill the agent mid-run and watch what durability and reliability do to your AI agents.


