PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Learning paths> Agentic Engineering with Promptise Foundry
Lesson 68 / 71
96%

Durable AI Agents: Crash Recovery and Safe Resume

How to make an AI agent survive a crash: journal its progress, resume from the last step, and use idempotency keys so side effects never run twice.

Intermediate16 minAgent Runtime
All 71 lessons↓
  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. ✓FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. ✓OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. ✓MCP Authentication in Python: API Keys, JWT and OAuth
  23. ✓Build an Authenticated MCP Server for Your AI Agent (Python)
  24. ✓MCP Security Best Practices: A Checklist for MCP Servers
  25. ✓Testing MCP Servers in Python: pytest Without a Live Server
  26. ✓Too Many MCP Tools? LLM Tool Selection, Measured
  27. ✓MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. ✓MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. ✓AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. ✓AI Agent Human in the Loop: Approve Tool Calls in Python
  31. ✓AI Agent Prompt Injection: How to Defend Against It
  32. ✓Build a Multi-Agent System in Python (and When Not To)
  33. ✓Context Engineering for AI Agents: Context Window Management
  34. ✓How to Deploy an AI Agent with Docker (2026 Guide)
  35. ✓LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. ✓MCP Long-Running Tasks: Background Jobs in Python
  37. ✓MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. ✓Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. ✓Self-Improving AI Agent: Learn From Tool Failures in Python
  40. ✓AI Agent Identity: Authenticate Agents to MCP Servers
  41. ✓Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. ✓Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. ✓What Is Agentic RAG? How It Differs from Classic RAG
  44. ✓AI Agent Memory in Python: Short-Term and Long-Term
  45. ✓Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. ✓AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. ✓Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. ✓Self-Modifying AI Agent in Python: Open Mode & Limits
  49. ✓CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. ✓Migrate from AutoGen: Agent Framework, Promptise and More
  51. ✓What Is an LLM Guardrail? Types, Examples and Limits
  52. ✓AI Agent Guardrails in Python: Block Injection, Redact PII
  53. ✓AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. ✓AI Agent Production Checklist: What to Check Before Launch
  55. ✓Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. ✓What Is Human in the Loop AI? Approvals for AI Agents
  57. ✓AI Agent Notifications: Webhook and Slack Alerts in Python
  58. ✓AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. ✓AI Agent Stuck in a Loop? How to Detect and Stop It
  60. ✓How to Run, Debug and Deploy an AI Agent in Python
  61. ✓AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. ✓How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. ✓AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. ✓How to Test LLM Prompts and AI Agents in Python
  65. ✓AI Agent Evals in CI: Run Them on Every Pull Request
  66. ✓Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. ✓Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)

Lessons in this path

  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. ✓FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. ✓OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. ✓MCP Authentication in Python: API Keys, JWT and OAuth
  23. ✓Build an Authenticated MCP Server for Your AI Agent (Python)
  24. ✓MCP Security Best Practices: A Checklist for MCP Servers
  25. ✓Testing MCP Servers in Python: pytest Without a Live Server
  26. ✓Too Many MCP Tools? LLM Tool Selection, Measured
  27. ✓MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. ✓MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. ✓AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. ✓AI Agent Human in the Loop: Approve Tool Calls in Python
  31. ✓AI Agent Prompt Injection: How to Defend Against It
  32. ✓Build a Multi-Agent System in Python (and When Not To)
  33. ✓Context Engineering for AI Agents: Context Window Management
  34. ✓How to Deploy an AI Agent with Docker (2026 Guide)
  35. ✓LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. ✓MCP Long-Running Tasks: Background Jobs in Python
  37. ✓MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. ✓Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. ✓Self-Improving AI Agent: Learn From Tool Failures in Python
  40. ✓AI Agent Identity: Authenticate Agents to MCP Servers
  41. ✓Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. ✓Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. ✓What Is Agentic RAG? How It Differs from Classic RAG
  44. ✓AI Agent Memory in Python: Short-Term and Long-Term
  45. ✓Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. ✓AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. ✓Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. ✓Self-Modifying AI Agent in Python: Open Mode & Limits
  49. ✓CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. ✓Migrate from AutoGen: Agent Framework, Promptise and More
  51. ✓What Is an LLM Guardrail? Types, Examples and Limits
  52. ✓AI Agent Guardrails in Python: Block Injection, Redact PII
  53. ✓AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. ✓AI Agent Production Checklist: What to Check Before Launch
  55. ✓Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. ✓What Is Human in the Loop AI? Approvals for AI Agents
  57. ✓AI Agent Notifications: Webhook and Slack Alerts in Python
  58. ✓AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. ✓AI Agent Stuck in a Loop? How to Detect and Stop It
  60. ✓How to Run, Debug and Deploy an AI Agent in Python
  61. ✓AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. ✓How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. ✓AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. ✓How to Test LLM Prompts and AI Agents in Python
  65. ✓AI Agent Evals in CI: Run Them on Every Pull Request
  66. ✓Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. ✓Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)
← Path overview

A long-running AI agent will be killed mid-run sooner or later: an out-of-memory kill, a deploy, a reclaimed spot instance, a kill -9. A durable AI agent survives that. It resumes from where it was instead of starting over, and it doesn't repeat the payments, emails or orders it already made. This guide explains how crash recovery works, why resuming alone still causes duplicate side effects, and how to build both halves, a journal and idempotency keys, with Promptise Foundry.

[01]

How do you make an AI agent durable?

Split durability into two jobs. First, position recovery: record each finished step in a journal, and on restart, replay the journal to find where to continue. Second, exactly-once side effects: give every tool that changes the outside world a stable idempotency key, derived from the business action, and make the tool or the downstream system refuse a second action with the same key. A journal alone resumes the agent, but it can't know whether the step that was running at the crash already sent the money.

  • Durability is two problems. Resuming the work, and not repeating side effects.

  • The danger window is between a side effect and the record of it. A crash there leaves the action done but unrecorded.

  • No ordering of writes removes that window. Only an idempotency key makes the repeat harmless.

  • The key must be stable. The same business action must always get the same key, whoever retries it.

  • Durable-execution platforms need idempotent steps too. They make recovery easier, not side effects exactly-once.


[02]

How does crash recovery work for AI agents?

An agent run is a sequence of steps: model calls, tool calls, and the work between them. A crash can stop it at any point. Recovery needs a durable record that survives the process, and a way to rebuild state from it.

The journal and replay

A journal is an append-only log of what the agent finished: "step 2 done", "state changed to running". Every so often you can also write a checkpoint, a full snapshot of the state. On restart, a replay engine loads the last checkpoint and applies the entries written after it, which rebuilds the state as it was just before the crash. This is event sourcing: the checkpoint is the snapshot, and the journal entries are the events.

The danger window

Text
1
2
3
4
5
6
7
8
9
10
11
step N:  ┌──────────────┐        ┌────────────────────┐
         │ side effect  │ ─────▶ │ journal "N done"   │
         │ (refund sent)│   ▲    └────────────────────┘
         └──────────────┘   │
                            └── crash here: the refund happened,
                                the journal says step N is not done

restart: replay says "resume at step N"
         ──▶ step N runs again ──▶ second refund, unless the tool
                                    sees the same idempotency key
                                    and does nothing

Moving the journal write before the side effect doesn't help. A crash in the new gap loses the side effect instead of duplicating it: the journal says "done", but the refund was never sent. There is no order of writes that closes the window, because the side effect and the journal live in different systems. What closes it is making the repeat harmless.

Idempotency keys

An idempotency key is an identifier for one business action, such as "refund order O-3". The tool sends it with the action, and the system that performs the action remembers the keys it has seen. A second request with a known key returns the first result and does nothing else. Payment providers support this directly: Stripe accepts an Idempotency-Key header and returns the saved response for a retry with the same key, as its idempotency docs describe.


[03]

When to use a journal, idempotency keys or a durable-execution platform

Situation

What you need

Short chat runs that a user can retry

Usually nothing beyond retries

A batch or queue worker that processes many items

A journal to resume, plus idempotency keys on side effects

Tools that move money, send messages or create orders

Idempotency keys, always, whether or not you journal

Read-only agents

A journal for progress, if runs are long; no keys needed

Many workers, long timers, cross-service workflows

A durable-execution platform such as Temporal or DBOS

Durable-execution platforms record each step's result and replay the workflow after a crash, so finished steps are not run again. They still ask for idempotent steps, because a step interrupted mid-run is retried. The Temporal docs recommend that activities be idempotent because they may be executed more than once, and the DBOS docs note that an interrupted step is retried during recovery. Whichever tool you pick, the idempotency key is your code's job. For a single agent process, a journal plus keys is often enough, with no extra platform to run.


[04]

What you need

  • Python 3.10 or later and Promptise Foundry 1.4.1 or later.

  • An OpenAI API key in OPENAI_API_KEY. The example uses openai:gpt-5-mini, and the whole demo costs about a cent.

  • A Unix-like system for the kill -9 demo (macOS or Linux).

  • About 15 minutes.

>_Terminal
pip install "promptise>=1.4.1"

[05]

Step 1: Build an agent that has side effects

The example is a refund agent. It works through five refund requests. For each one, an AI agent reads the customer's message, calls issue_refund, and replies. The refund is the side effect: a line in a ledger file that stands in for a payment provider. In production it would be an API call that moves money.

Pythonrefund_agent.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
"""A durable refund agent: it journals its progress and resumes after a crash."""
import asyncio
import json
import os
import signal
from pathlib import Path

from langchain_core.tools import tool
from promptise import build_agent
from promptise.runtime.journal import FileJournal, JournalEntry, ReplayEngine

PROCESS_ID = "refund-agent"
REQUESTS = [  # (order id, amount in cents, the customer's message)
    ("O-1", 1999, "The mug arrived broken, please refund it."),
    ("O-2", 4500, "I was charged for an order I cancelled."),
    ("O-3", 1250, "Wrong size. I sent it back last week."),
    ("O-4", 899, "The package never arrived."),
    ("O-5", 3200, "I returned it, tracking says delivered to you."),
]
AMOUNTS = {order_id: cents for order_id, cents, _ in REQUESTS}
LEDGER = Path(os.environ.get("LEDGER", "ledger.jsonl"))  # stands in for the payment provider
IDEMPOTENT = os.environ.get("IDEMPOTENT", "1") == "1"
CRASH_AFTER_REFUND = os.environ.get("CRASH_AFTER_REFUND", "")  # test hook: an order id


def issued_keys() -> set[str]:
    if not LEDGER.exists():
        return set()
    return {json.loads(line)["key"] for line in LEDGER.read_text().splitlines() if line}


@tool
def issue_refund(order_id: str) -> str:
    """Refund an order in full. Call it once per order."""
    if order_id not in AMOUNTS:
        return f"Unknown order {order_id}."
    key = f"refund-{order_id}"  # stable: derived from the order, not from the attempt or the model
    if IDEMPOTENT and key in issued_keys():
        return f"The refund for {order_id} was already issued; nothing was charged again."
    with LEDGER.open("a") as f:  # the side effect: money moves here
        f.write(json.dumps({"key": key, "order_id": order_id, "cents": AMOUNTS[order_id]}) + "\n")
    if CRASH_AFTER_REFUND == order_id:
        os.kill(os.getpid(), signal.SIGKILL)  # a real kill -9, after the refund, before the journal write
    return f"Refunded ${AMOUNTS[order_id] / 100:.2f} for {order_id}."


async def main():
    journal = FileJournal(base_path=os.environ.get("JOURNAL_DIR", ".promptise/journal"))
    recovered = await ReplayEngine(journal).recover(PROCESS_ID)
    start = recovered["context_state"].get("last_done", -1) + 1  # resume after the last finished request
    if start:
        print(f"recovered: last_done={start - 1}, resuming at {REQUESTS[start][0]}")

    agent = await build_agent(
        model="openai:gpt-5-mini", servers={}, extra_tools=[issue_refund],
        instructions="You handle refund requests for an online shop. Refund every request in full "
                     "with issue_refund, then reply to the customer in one short sentence.")
    for i in range(start, len(REQUESTS)):
        order_id, _, message = REQUESTS[i]
        result = await agent.ainvoke({"messages": [{"role": "user", "content": f"Order {order_id}: {message}"}]})
        print(f"{order_id}: {result['messages'][-1].content}")
        # Record progress only after the step is complete.
        await journal.append(JournalEntry(process_id=PROCESS_ID, entry_type="context_update",
                                          data={"key": "last_done", "value": i}))
    await journal.append(JournalEntry(process_id=PROCESS_ID, entry_type="state_transition",
                                      data={"to_state": "completed"}))
    await agent.shutdown()


if __name__ == "__main__":
    asyncio.run(main())

Three design choices in issue_refund matter for durability:

  • The amount comes from your records, not from the model. The model passes only the order ID. A model can't invent a larger refund if the tool doesn't take an amount.

  • The key is derived from the order. refund-O-3 is the same on the first attempt and on every retry, whoever retries.

  • The crash is a test hook. CRASH_AFTER_REFUND makes the process send itself SIGKILL right after a refund, before the journal write. That puts the crash exactly in the danger window.


[06]

Step 2: Journal progress after each step

The agent records progress with Promptise's FileJournal, which writes one JSON line per entry. After each request is complete, it appends a context_update that sets last_done to the request's index:

Python
from promptise.runtime.journal import FileJournal, JournalEntry

journal = FileJournal(base_path=".promptise/journal")
await journal.append(JournalEntry(process_id="refund-agent", entry_type="context_update",
                                  data={"key": "last_done", "value": i}))

Write the entry only after the step is finished, including its side effects. An entry that says "done" before the work is done turns a crash into lost work. Keep entries small and about progress: which items are finished, which state the process is in. The journal docs list the entry types.


[07]

Step 3: Recover the position on restart

On start, the agent asks the ReplayEngine for its state and continues after the last finished request:

Python
from promptise.runtime.journal import ReplayEngine

recovered = await ReplayEngine(journal).recover("refund-agent")
start = recovered["context_state"].get("last_done", -1) + 1

recover() loads the last checkpoint, if there is one, replays the context_update and state_transition entries written after it, and returns the rebuilt context_state and lifecycle_state. The replay docs describe the algorithm. Replay rebuilds state. It never runs or skips a tool. Deciding what to do with the recovered state is your code's job.


[08]

Step 4: Crash the agent and resume it

crash_demo.py runs the agent in a subprocess, kills it with SIGKILL right after the refund for O-3, and restarts it. It does this twice: once with the idempotency check turned off, and once with it on.

Pythoncrash_demo.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
"""Kill the refund agent with kill -9 right after it refunds O-3, restart it, and check the ledger."""
import collections
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

HERE = Path(__file__).parent


def run(env):
    proc = subprocess.run([sys.executable, str(HERE / "refund_agent.py")], env={**os.environ, "PYTHONUNBUFFERED": "1", **env},
                          capture_output=True, text=True)
    print(proc.stdout.rstrip())
    print(f"[exit code {proc.returncode}{' = killed by SIGKILL' if proc.returncode == -9 else ''}]")


def demo(idempotent: bool):
    print(f"==== idempotency key {'ON' if idempotent else 'OFF'} ====")
    work = Path(tempfile.mkdtemp())
    env = {"JOURNAL_DIR": str(work / "journal"), "LEDGER": str(work / "ledger.jsonl"),
           "IDEMPOTENT": "1" if idempotent else "0"}
    run({**env, "CRASH_AFTER_REFUND": "O-3"})  # first run: crashes inside O-3's refund
    print("-- restart --")
    run({**env, "CRASH_AFTER_REFUND": ""})     # second run: recovers and resumes
    ledger = [json.loads(line)["order_id"] for line in (work / "ledger.jsonl").read_text().splitlines()]
    twice = [o for o, n in collections.Counter(ledger).items() if n > 1]
    print(f"ledger: {len(ledger)} refunds for {len(set(ledger))} orders; refunded twice: {twice or 'none'}\n")
    shutil.rmtree(work)


demo(idempotent=False)
demo(idempotent=True)
Output
==== idempotency key OFF ====
O-1: Your refund for order O-1 has been issued in full.
O-2: Your payment for order O-2 has been fully refunded.
[exit code -9 = killed by SIGKILL]
-- restart --
recovered: last_done=1, resuming at O-3
O-3: Your refund for order O-3 has been issued in full.
O-4: I've issued a full refund for order O-4.
O-5: Your refund for order O-5 has been issued in full.
[exit code 0]
ledger: 6 refunds for 5 orders; refunded twice: ['O-3']

==== idempotency key ON ====
O-1: Your refund for order O-1 has been issued.
O-2: I've refunded your payment for order O-2 in full.
[exit code -9 = killed by SIGKILL]
-- restart --
recovered: last_done=1, resuming at O-3
O-3: Your refund for order O-3 has already been issued and should appear on your original payment method within 5–10 business days.
O-4: Your refund for order O-4 has been issued in full.
O-5: Your refund for order O-5 has been processed in full.
[exit code 0]
ledger: 5 refunds for 5 orders; refunded twice: none

Read the first half closely. The agent finished O-1 and O-2, refunded O-3, and was killed before it could journal O-3. On restart, recovery worked exactly as designed: the journal said the last finished request was index 1 (O-2), so the agent resumed at O-3. And because the check was off, it refunded O-3 a second time. Position recovery was correct and still produced a duplicate refund. That is the danger window from the diagram.


[09]

Step 5: Make the side effect idempotent

In the second half, the same crash and the same resume happened. This time issue_refund found the key refund-O-3 in the ledger and returned "already issued" instead of refunding again. Five orders, five refunds. The agent read that result and told the customer the refund was already issued.

The check is a few lines in the tool:

Python
key = f"refund-{order_id}"
if key in issued_keys():
    return f"The refund for {order_id} was already issued; nothing was charged again."

In this demo the check reads the ledger, because the ledger is the system of record. That is the important part: the check must look where the side effect is recorded, not at the agent's own journal, which by definition doesn't know about the step that crashed. In production, pass the key to the system that performs the action and let it enforce uniqueness:

  • A payment provider. Send the key as the provider's idempotency key. Note the provider's retention: Stripe, for example, may prune keys after 24 hours, so a retry days later is a new request.

  • Your own database. Make the action a row with a unique constraint on the key, written in the same transaction as the work. A duplicate insert fails instead of paying twice.

  • Email, SMS or chat messages. Keep a table of sent keys and check it in the same transaction that records the send, or use the provider's deduplication ID if it has one.

One more detail from the output: the agent's reply for O-3 promised a refund timeline that no tool returned. Durability doesn't fix what the model says. Tool results that state exactly what happened, and reply templates for customer-facing text, do.


[10]

The complete example

Text
durable-refund-agent/
├── refund_agent.py   # the agent, the journal and the idempotent tool (steps 1-3, 5)
└── crash_demo.py     # kill -9 after a refund, restart, check the ledger (step 4)
>_Terminal
pip install "promptise>=1.4.1"
export OPENAI_API_KEY=sk-...
python crash_demo.py             # both variants, about a cent
python refund_agent.py           # run the agent on its own; rerun it to see it resume

Running refund_agent.py directly writes .promptise/journal/refund-agent.jsonl and ledger.jsonl in the current directory. Delete both to start over.


[11]

Running it in production

Use a shared, transactional journal for many workers

FileJournal suits one process on one machine. It writes with plain file appends and doesn't call fsync, so a process kill is safe, but a power loss can drop the last lines, and the checkpoint file is rewritten in place. For several workers or several machines, implement the journal protocol over a database such as Postgres, and keep the idempotency keys in the same database so the progress record and the key check share a transaction.

Journal agent processes in the runtime

Promptise's AgentProcess can journal its own lifecycle, invocations and checkpoints with ProcessConfig(journal=JournalConfig(level="checkpoint")), or every side effect with level="full". On restart you read the state back with ReplayEngine, as above. restart_policy="on_failure" restarts a process that entered the FAILED state. It doesn't restart a killed operating-system process, so pair it with a supervisor such as systemd, Kubernetes or your platform's restart policy. See agent processes.

Make long jobs asynchronous

A tool that waits 20 minutes for an export keeps the danger window open for 20 minutes. Start the job, return a job ID, and let the completion trigger the next step, as long-running MCP tasks and event-driven agents show. Short steps are cheaper to repeat.

Gate irreversible actions

Idempotency stops repeats. It doesn't stop a wrong first action. Put tools that pay, delete or send behind an approval gate and a budget for irreversible actions. See human-in-the-loop approval and AI agent cost control.

Test crashes on purpose

A recovery path that never runs is a guess. Keep a crash test like crash_demo.py in CI, with a scripted model so it needs no API key, and kill the worker at each phase: before the side effect, between the side effect and the journal write, and after the journal write. Every phase should end with each item done exactly once.


[12]

Common problems and fixes

Symptom

Cause

Fix

An action runs twice after a restart

Crash between the side effect and the journal write

Add an idempotency key that the system of record checks

An action is missing after a restart

The journal was written before the side effect

Journal after the side effect, and rely on the key for repeats

The key check doesn't catch the repeat

The key includes a timestamp, a random value or the model's wording

Derive the key from the business action, such as the order ID

The key check reads the agent's journal

The journal doesn't know about the step that crashed

Check where the side effect is recorded: the provider or your database

recover() returns lifecycle_state="created"

The process never journaled a state transition before the crash

Journal a running transition at start if you need the lifecycle; resume from context_state

A retry days later charges again

The provider pruned the idempotency key

Keep your own record of issued keys as well

Output printed before a crash is missing

stdout was buffered when the process was killed

Use unbuffered output or flush logs; trust the journal, not logs


[13]

Limitations

  • Promptise recovers position, not side effects. The journal and the replay engine rebuild state. There is no automatic deduplication of tool calls across a crash. The MCP idempotent_hint annotation is advice to clients, not enforcement.

  • The agent re-decides the interrupted step. On resume, the model handles the in-flight request again and may decide differently. If that matters, journal the decision before acting and replay it instead of asking the model again.

  • `FileJournal` is single-node. Use a shared, transactional backend for several workers.

  • Idempotency needs support downstream. A tool that calls an API without idempotency support needs its own record of what it did.

  • This is not a workflow engine. For timers that last days, fan-out across services, or managed retries across many workers, a durable-execution platform is the better fit.


[14]

Frequently asked questions

What is a durable AI agent?

A durable AI agent survives a crash. When its process is killed mid-run and restarted, it resumes from where it was instead of starting over, and it doesn't repeat side effects it already performed, such as payments or emails. Durability comes from a journal of finished steps for resuming, and idempotency keys on every tool that changes the outside world.

How do I resume an AI agent after a crash?

Record each finished step in a durable journal, and on restart, replay the journal to rebuild the state and find the first unfinished step. In Promptise, FileJournal records the entries and ReplayEngine.recover() returns the rebuilt context_state, from which you read the resume position. Then make the step that was running at the crash safe to repeat.

How do I stop an AI agent from doing something twice after a crash?

Give each side-effecting tool a stable idempotency key, derived from the business action, such as refund-O-3, and make the system that performs the action reject a second request with the same key. A journal can't prevent the repeat on its own, because a crash between the action and the journal write leaves the action done but unrecorded.

Do I need Temporal or DBOS for a durable AI agent?

Not for crash recovery of a single agent process. A journal plus idempotency keys covers resuming and safe repeats. Durable-execution platforms help with many workers, long timers and cross-service workflows, and they record step results so finished steps don't rerun. They still expect idempotent steps, because an interrupted step is retried, so you write the keys either way.

Where does a crash cause a duplicate side effect?

In the window between performing the side effect and recording that it happened. A crash there leaves the action done but unrecorded, so recovery runs the step again. Writing the record first doesn't help, because then a crash loses the action instead. Only an idempotency key, checked where the side effect is recorded, makes the repeat harmless.

What makes a good idempotency key?

It identifies one business action and is the same on every retry: an order ID, an invoice ID, or a request ID created once and stored before the first attempt. Don't use timestamps, attempt counters or text the model wrote, because they change on retry. Avoid personal data in keys, and check how long the downstream system keeps them.


[15]

Next steps

  • Start long jobs and finish them later: long-running MCP tasks.

  • Wake agents on events and schedules: event-driven AI agents.

  • Run agents as long-lived processes: the autonomous AI agent runtime.

  • Ship it: deploy AI agents in production.

  • See where durability fits in an agent: what is an agent harness?.

  • Reference: journal, replay engine and agent processes in the Promptise docs.

← PreviousEvent-Driven AI Agents: Webhook, Cron and File TriggersNext lesson →Deploy AI Agents in Production: Long-Running Python Agents