ealeng

Episode 38 of 56 · Resilience

Durable agents: kill it mid-refund, then resume it

A support agent killed with SIGKILL at chosen moments, 420 times, then restarted three ways: from scratch, from its own journal, and as a DBOS workflow. Saving progress after every step keeps the work, but a kill right after a refund still sends it twice. Idempotency keys make the repeat harmless, and random kills never find the moment.

Lab 13 · Resilience12 min read
Video transcript7:56

0:00Cold open

0:01

I killed my support agent on purpose, a split second after it refunded a customer.

0:07

It had been writing down its progress after every step. So I restarted it from those notes.

0:12

This is the shop’s own record afterwards. Dana asked for one refund, seventy-nine fifty. She got two.

0:21

And the agent told her it had refunded her once.

0:25

It wrote down every step. So how did it pay twice?

0:30Intro

0:31

Hands-on AI engineering: resilience, part three.

0:35

Today: what happens when the agent itself dies in the middle of a task.

0:40Where we are

0:42

I’m Sofia, a support engineer. In parts one and two, the model’s service failed, and we kept answering anyway.

0:49

Today the failure is in our own program.

0:53

The shop is made up: Lantern Outfitters. I recorded the AI’s replies once and play them back, so a rerun that reaches the same point gets the same reply. And every crash is on purpose.

1:0701 · Dana’s task

1:08

First, what the agent does. Dana writes in: one of her two sleeping pads arrived torn.

1:15

The agent works in steps. The model decides the next step. Then a tool does it: look up the customer, her orders, the order, and its refunds.

1:25

Then the step that matters: the refund. A step that changes the shop, I’ll call a write.

1:31

Last, the model writes the answer to Dana.

1:3602 · Paying twice

1:37

You’ve probably felt this moment yourself. You pay a bill online, and click Pay.

1:42

The bank moves the money. Then your power cuts out, before you see the word: Paid.

1:48

Did it go through? You don’t know. Click Pay again, and you might pay twice.

1:54

My agent can hit the same moment. The shop is the bank: it keeps its own records, even when the agent dies.

2:0203 · The test

2:04

To crash the agent on purpose, I pull the plug, with a signal called SIGKILL. The program stops mid-step, with no chance to tidy up.

2:13

Fourteen tasks like Dana’s. I kill each one at a moment I pick, then start it again.

2:20

Then I compare it with a run that was never killed. Was anything done twice? Did the customer get the same answer?

2:2804 · Start over

2:30

The simplest recovery: start the whole task again from the top, as if nothing had happened.

2:36

Here I killed Dana’s task late, while the answer was being written.

2:40

Starting over, the agent did every step again. The refund too, though the shop already had one.

2:47

When I killed all fourteen tasks at that same late moment, starting over made five extra writes. Dana’s second refund was one. And only two of the fourteen answers came out the same.

3:0105 · A journal

3:03

The fix everyone reaches for: write down progress after every step. Like ticking off a checklist.

3:09

That record is called a journal. After each step, the agent saves the conversation so far.

3:16

On a restart, it reads the journal, and carries on after the last tick.

3:22

Killed during the answer, it now redoes just the answer. All fourteen answers matched a run that was never killed.

3:30

The half-written answer was never saved. So it’s thrown away, and written again.

3:3606 · The gap

3:37

Now the worst moment. The shop has just saved Dana’s refund. The agent hasn’t ticked it yet. And I kill it right there.

3:46

Like the power cut after the bank moved the money. The journal says the refund was asked for. Not that it happened. So, on the restart, what does the agent do?

3:59

It sends the refund again.

4:02

Twelve tasks get as far as a write. Two never do. Every one of the twelve sent its write a second time.

4:09

How wide is my journal’s gap? About a tenth of a millisecond. I hit it on purpose, with a trigger in the code.

4:17

Here the shop is on the same machine. With a real payment service, the gap includes the reply’s trip back over the network, so it’s much wider.

4:2807 · What the repeat did

4:29

In two tasks, the second write went through. Dana had ordered two pads, so a second refund was still allowed. The other was an address, changed twice to the same address.

4:41

In the other ten, the shop’s own rules refused it. You can’t refund more than what’s left, or cancel an order twice.

4:49

Refused sounds safe. But look at Grace. Her refund went through, once.

4:54

On the restart, the agent tried the refund again. The shop refused, twice. So the agent told her the refund was blocked, and passed her to a person.

5:05

In the end, only one of the twelve answers matched a run that was never killed.

5:1208 · DBOS

5:13

Maybe my journal is too simple. So I tried DBOS, a library that keeps the journal for you, in a database.

5:21

Same twelve kills. Same result as my journal, task by task.

5:27

Why? DBOS ticks a step after the step comes back. The refund never came back. So on the restart, it runs again.

5:36

It promises every step will finish, even if that means running it twice.

5:4209 · Idempotency keys

5:44

Back to the bill. A good bank gives every payment a reference number. Send the same reference twice, and it pays once.

5:52

For a write, that number is called an idempotency key: an ID sent along with every write.

5:59

The shop runs a key the first time, and remembers the result. The same key again just gets that first result back.

6:07

So the key must stay the same on a repeat. I build it from which run, and which step. A new random ID every time would look like a new payment.

6:17

So, with keys, how many writes were done twice?

6:22

None. On my journal and on DBOS, all twelve answers matched a run that was never killed.

6:3110 · Random kills

6:32

So why wouldn’t random crash tests catch this? I also killed the agent at random moments: two hundred and ten times.

6:40

In my replay, each tool pauses for a set moment, like a real service would.

6:46

So most of a run is waiting: on the model’s recorded reply, or on a tool. Most kills landed there.

6:53

None hit my journal’s gap. At a tenth of a millisecond, I’d expect about one hit in thirteen thousand kills.

7:02

So you have to aim at it on purpose.

7:0611 · What I do now

7:07

So here’s what I do now. Every write gets an idempotency key that stays the same when the step is repeated.

7:15

I save progress after every step, with my own journal or a library like DBOS. The journal keeps the work. The key makes a repeat harmless.

7:26

I kill the agent on purpose, right after a write, not only at random. And nothing acts on half an answer.

7:33

Next time: failures that never raise an error. An answer that breaks the rules, search that quietly returns the wrong pages, and a request that’s too slow.Sofia: One key per write.

7:44Summary

7:45

A journal keeps the work. It doesn’t make a write happen once.

7:49

An idempotency key does. And test the moment right after a write.Sofia: Thanks!

Key takeaways

  • ✓An agent works in steps: the model picks a step, a tool does it. A write is a step that changes the shop (a refund, a cancellation, an address change, a hand-off to a person). The shop keeps its own records, so a write it committed stays committed whatever happens to the agent.
  • ✓Starting over repeats everything. Killed while writing the answer, restarting from scratch redid 75 model calls in 14 runs, made 5 extra writes (Dana's second $79.50 refund among them), and gave the same answer in only 2 of 14.
  • ✓A journal saves the conversation after every step, and a restart carries on after the last save. Killed mid-answer, every saving strategy redid just that one call and gave the same answer in 14 of 14.
  • ✓There's always a gap between the shop committing a write and the agent saving that it happened: about 0.13 ms here. Killed right there, all 12 restarts sent the write again. 2 were done twice (Dana's refund, an address), 10 were refused by the shop's rules, and only 1 of 12 answers matched. DBOS did exactly the same, task by task: it records a step after the step returns.
  • ✓An idempotency key is an ID sent with every write. The service runs a key once and returns the first result to a repeat. Build it from the run and the step (run id + tool call id, or workflow id + step number), never a fresh random ID. With keys: 0 done twice, 12 of 12 same answers, on the journal and on DBOS.
  • ✓210 random kills never hit the gap. Most landed while the agent waited on the model or a tool, and at 0.13 ms you'd expect about one hit in 13,000 kills. Aim a kill right after a write on purpose.

When an agent dies in the middle of a task, something has to start it again. How it starts again decides whether the customer gets one refund or two. This chapter kills a support agent on purpose with SIGKILL, at chosen moments and at random ones, and checks every restart against a run that was never killed: were any writes done twice, and did the customer get the same answer?

The source is Lab 13 (Resilience), script 06_crash_resume.py, recorded in October 2026 on one laptop (MacBook Pro M4 Max, Python 3.14.7, DBOS 3.3.0 on SQLite). The agent is Lab 09's support agent for a made-up shop, Lantern Outfitters, on gpt-5.4-nano. Its replies were recorded once and are replayed as a stream, so a restart that reaches the same point gets the same reply, and "the same answer" has an exact meaning. What's tested is the harness around the model, not the API. The lab's other parts, failures of the model's service (retries in chapter 36, fallbacks and circuit breakers in chapter 37) and failures that raise no error (chapter 39), have their own chapters.

Steps, and the steps that change things

The agent works in steps. The model decides the next step, and a tool does it. Dana writes in: one of the two sleeping pads from her order arrived torn. Her task takes six model calls and five tools:

  1. look up the customer, her orders, the order, and its refunds (four reads),
  2. refund $79.50,
  3. write the answer to Dana.

A step that changes the shop is a write: a refund, a cancellation, an address change, an email, a hand-off to a person. The shop runs in its own SQLite database, and each tool call is one transaction. So a refund the shop committed stays committed, whatever happens to the agent afterwards.

It's the moment you know from paying a bill online. You click Pay, the bank moves the money, and your power cuts out before you see "Paid". Did it go through? Click again and you might pay twice. The shop is the bank: its records survive the agent.

The test

A hook sends the agent's process SIGKILL (like kill -9): it stops mid-step, and no exception handler or cleanup runs. Then the parent starts the task again and compares the result with a run that was never killed. 14 support tasks, 12 of which commit a write, each killed at three chosen moments and three random ones, with five ways of recovering: 14 × 5 × 6 = 420 kill-and-resume runs. Every killed run finished; the question is how.

The three chosen moments:

  • during the first reply: halfway through streaming the model's first reply, before anything happened in the shop,
  • during the answer: halfway through streaming the final answer, after every tool ran,
  • right after a write: inside the write tool, after the shop committed it and before the result reached the agent.

Start over, or keep a journal

The simplest recovery is to start over: run the whole task again from the top. Killed during the answer, that redoes every step, the refund included, though the shop already has one. Across the 14 tasks, starting over redid 75 model calls, made 5 extra writes (Dana's second refund among them), and gave the same answer in only 2 of 14.

The fix everyone reaches for is a journal (also called a checkpoint): after each step, save the conversation so far, like ticking off a checklist. The save is atomic (write a temporary file, then rename it), so a kill mid-save never leaves half a journal. On a restart, the agent reads the journal and carries on after the last tick. Killed during the answer, it redoes just the answer: one model call per run, 14 of 14 answers the same as a run never killed. The half-written answer was never saved, so it's thrown away and written again.

Pick a moment and a way to recover. The strip is Dana's task; the numbers below it are all the tasks killed at that moment:

Try it · Kill it, then restart itLab 13 · script 06 · Dana's task, then all 14 tasks

When the agent is killed

How the restart recovers

Inside the refund tool: the shop has committed the refund, and the agent hasn't seen the result or saved it. 2 of the 14 tasks never commit a write, so this kill point has 12 runs.

🤖 model
from the journal
find customer
from the journal
🤖 model
from the journal
list orders
from the journal
🤖 model
from the journal
get order
from the journal
🤖 model
from the journal
get refunds
from the journal
🤖 model
from the journal
refund $79.50
✕ killed here · done twice
🤖 answer
runs once

Dana's shop afterwards: $79.50 refunded twice. The journal said the refund was asked for, not that it happened. She ordered two pads, so the second refund was within the limit.

All 12 tasks, killed right after the refund

Same write twice
2
Same shop
5/12
Same answer
1/12
Model calls redone
20

Compared with a run that was never killed. Every killed run finished. "Same shop" = the same writes in the shop's own records. The lab's table counts 6 extra writes: 2 the same write done twice, and 4 different writes the agent made after the shop refused a repeat.

The gap after a write

Now the worst moment. The shop has just committed Dana's refund. The agent hasn't saved it yet. Kill it right there.

The journal says the refund was asked for, not that it happened. So the restart sends it again. All 12 tasks that get as far as a write sent their write a second time. (The other 2 never commit one.)

How wide is that gap? A separate timing run with the lab's own shop and journal code (no model) measured it from the shop's commit to the saved journal: a median of 0.13 ms (at most 0.35 ms) over 200 runs. That's with the shop on the same machine. With a real payment service, the gap includes the reply's trip back over the network, so it's much wider. A trigger in the code kills the agent right inside it.

What the repeat did

Try it · What the repeat didLab 13 · script 06 · 12 kills right after a write
Done twice
2
Refused
10
Same answer
1/12
Customer asksKilled afterSent againThenSame answer
Grace: Trekking poles never arrivedrefund $68refused by the shophanded to a person✕
Dana: One of two sleeping pads arrived tornrefund $79.50done twice—✕
Tomas: Tent with a snapped polerefundrefused by the shop—✕
Grace: Rain jacket leaks at the seamsrefundrefused by the shop—✕
Sam: Boots too smallrefundrefused by the shop—✕
Ruth: Refund the sleeping bagrefundrefused by the shop—✕
Ruth: Sleeping pad with a slow leakrefundrefused by the shophanded to a person✕
Dana: Cancel the lantern ordercancel orderrefused by the shop—✕
Owen: Ship to my new addresschange addressdone twice—✓
Tomas: Cancel the stove, email mecancel orderrefused by the shop—✕
Priya: Refund all four mugsrefundrefused by the shophanded to a person✕
Ruth: Cancel the mugs, refund the padcancel orderrefused by the shopan extra email✕

Every restart sent the killed write again. The shop's own rules refused 10 (7 refunds over what was left, 3 cancellations of a cancelled order); 2 went through. Refused isn't harmless: Grace was refunded $68 once, and then told the refund was blocked and handed to a person. Journal and DBOS were identical, task by task.

  • Done twice: 2. Dana ordered two pads, so a second $79.50 refund was still within what was left to refund. Owen's address was changed twice, to the same address.
  • Refused by the shop's rules: 10. You can't refund more than what's left (7 refunds), or cancel an order that's already cancelled (3).
  • Refused isn't harmless. When the repeat was refused, the conversation changed, and the answer with it. Grace's $68 refund went through once. On the restart, the agent tried it again, the shop refused twice, and the agent told her the refund was blocked and passed her to a person. In 4 of the 10 refusals the agent went on to make a different write (3 hand-offs to a person, 1 extra email).
  • Same answer: 1 of 12. Only Owen's.

A note on counting: the lab's README table says the journal had "6 writes done twice". That column counts extra writes of any kind against the run never killed: the 2 repeats that went through plus the 4 different writes made after a refusal. The shop's own logs give the split above.

Killed right after a write (12 runs) Same write done twice Same answer Model calls redone
Start over 2 0/12 51
Journal 2 1/12 20
DBOS 2 1/12 20
Journal + keys 0 12/12 0
DBOS + keys 0 12/12 0

DBOS: the same gap

Maybe a hand-written journal is too simple. DBOS is a library for durable workflows: you mark functions as steps of a workflow, and it records each step's result in a database (SQLite here), so a restarted process finishes the workflow from its last recorded step. Temporal, Restate and Inngest do the same job.

@DBOS.step()
def tool_dbos_step(name: str, raw_args: str, keys: bool) -> dict:
    key = f"{DBOS.workflow_id}:{DBOS.step_id}" if keys else None
    result, _ = _dbos["shop"].call(name, raw_args, key)
    return result

Same 12 kills, same result as the journal, task by task. DBOS records a step after the step returns. The refund step never returned, so on recovery it ran again. That's the promise of durable execution: every step will finish, even if that means running it twice (at least once, not exactly once).

Idempotency keys

A good bank gives every payment a reference number. Send the same reference twice and it pays once. For a write, that number is an idempotency key: an ID sent along with the write. The service runs a key it hasn't seen and remembers the result; the same key again just gets that first result back.

The key must stay the same when the step is repeated, so build it from which run and which step:

  • the journal: run id + tool call id, such as T07:call_YUjP…,
  • DBOS: workflow id + step number, such as T07:10.

A new random ID made inside the step would be new after the restart too, and look like a brand-new refund.

With keys, on the journal and on DBOS: 0 writes done twice, 12 of 12 answers the same, and no model call redone. The journal keeps the work; the key makes a repeat harmless. Neither does the other's job.

If a service doesn't accept keys, the lab's answer key suggests checking before writing (does a refund for this order and item already exist?) and recording the intent before the call, so a resumed run can look the result up. Don't count on the service's own rules to catch it.

Random kills miss it

The lab also killed the agent at random moments: 3 per task per strategy, 210 in all, each landing anywhere between 5% and 95% of the run.

Try it · Random killsLab 13 · script 06 · 210 kills at random moments

Where the 210 random kills landed

Inside a tool (each pauses 0.15 s)87
A tool call streaming in59
Python starting up33
The answer streaming30
As the run finished1

Green: waiting on the model's replayed reply or a tool, 176 of 210. No random kill made a journal or DBOS run repeat a write.

How wide is the gap between a write and saving it?

Expected hits in 210 kills
0.016
About one hit in
13,000 kills

With the shop on the same machine, the gap from the shop's commit to the saved journal was 0.13 ms (median of 200). A 2-second run gives a random kill almost nothing to hit, so the test has to aim at the gap on purpose.

Expected hits: for each of the 14 tasks, 15 kills × writes × gap ÷ (0.9 × run length), since each kill lands uniformly between 5% and 95% of a replayed run (each tool pauses 0.15 s). Wider gaps add to the run length too.

In the replay, each tool pauses 0.15 s, like a real service would, and the model's reply streams in. So most of a run is waiting, and 176 of the 210 kills landed while the agent waited on the model or a tool (8 of them inside the refund tool, before its commit). With a journal or DBOS, no random kill repeated a write: 42 of 42 answers the same for each saving strategy. Starting over made 3 extra writes and matched 32 of 42.

None hit the gap. At 0.13 ms in a 2-second run, you'd expect about one hit in 13,000 kills. That's why crash bugs survive random testing: aim the kill at the gap on purpose.

Random kills still earn their place. In the lab's first run, one landed between two tool calls the model had asked for at once, after the first result was saved. The resume looked for unfinished calls only in the last message (which was that first result), skipped the second call, and the API rejected the conversation: a tool call with no result. Lab 09's harness had the same bug, now fixed in both. No chosen kill point would have found it.

Half an answer

Killed while the answer streamed, every strategy threw the half-written answer away and asked the model again, and a user watching would see the answer start over. That's only safe because nothing acted on the half answer. Don't store, send or act on model output until it's complete.

Caveats

  • A made-up shop, recorded replies, kills on purpose. 14 tasks on one laptop; the replay makes "same answer" exact, and a live model would vary on its own.
  • The gap was measured with the shop in the same process. 0.13 ms is the journal path with a local SQLite shop. A real payment service's gap includes a network round trip.
  • The waits are staged. Each tool pauses a fixed 0.15 s, and the 2.1 s run is a replay. Where random kills land depends on that.
  • The DBOS gap wasn't timed separately. Its outcome matched the journal task by task.
  • One worker on SQLite. In production, DBOS or Temporal need a shared database (Postgres) and workers that can take over each other's runs; the lab didn't test that.
  • The shop's rules did some of the work. Without them, more of the 10 refused repeats would have gone through.

The checklist

  1. Give every write an idempotency key that stays the same when the step is repeated: run + step, never a fresh random ID.
  2. Save progress after every step, atomically, with your own journal or a durable-workflow library. The journal keeps the work; the key makes a repeat harmless.
  3. Know what your workflow engine promises. Steps run at least once. "Exactly once" holds only for services that deduplicate by key.
  4. Kill the agent on purpose right after a write, not only at random, and compare with a run that was never killed: writes done twice, shop state, answer.
  5. Keep random kills too. They find what you didn't think to aim at, like a crash between two parallel tool calls.
  6. Don't act on half an answer. Store, send or act on output only once it's complete.
  7. When a service has no keys, check before writing and record the intent before the call.

Practice: kill it, then resume it

Write the three pieces the chapter is about: a shop that runs each idempotency key once, a stable key from the run and the step, and a journaled runner that skips finished steps, saves after each one, and can be killed in the gap right after a write. Then the random-kill arithmetic. The tests replay the lab's cases: Dana refunded twice by starting over and by the journal, Grace refunded once and told it was blocked, the repeat made harmless by a stable key and not by a random one, and about one hit in 13,000 random kills.

Try it · Practice in PythonRuns in your browser · ⌘/Ctrl + Enter to run
durable_practice.py112 lines

Quiz

Try it · Check your understanding0 of 7 answered
  1. 1.Why does a refund the shop committed survive the agent being killed?

  2. 2.Killed while writing the final answer, what does starting over from scratch do?

  3. 3.The agent saves its journal after every step. Killed right after the shop committed Dana's refund, what happens on the restart?

  4. 4.In 10 of the 12 repeats, the shop's rules refused the second write. Why isn't that safe enough?

  5. 5.Why did DBOS give exactly the same results as the hand-written journal?

  6. 6.Which idempotency key protects a refund from a crash?

  7. 7.Why did 210 random kills never hit the gap after a write?

Check your understanding

Try each question before opening its answer.

What makes a step a "write", and why does it matter in a crash?

A write changes something outside the agent: a refund, a cancellation, an address change, an email, a hand-off. The shop commits it in its own records, so it survives the agent being killed, and a restart that doesn't know about it can do it again.

Killed during the final answer, how did starting over compare with a journal?

Starting over redid every step: 75 model calls in 14 runs, 5 extra writes (Dana's refund done twice among them), and the same answer in 2 of 14. The journal redid only the answer: one call per run, 14 of 14 the same.

The agent saves after every step. How can it still refund twice?

There's a gap between the shop committing the refund and the agent saving that it happened, about 0.13 ms here. Killed there, the journal says the refund was asked for, not that it happened, so the restart sends it again. All 12 tasks killed there did.

Why is a refused repeat still a problem?

The refusal changes the conversation. Grace was refunded once and then told the refund was blocked and passed to a person. Only 1 of 12 answers matched a run never killed, and in 4 refusals the agent went on to make a different write.

Does a durable-workflow library like DBOS fix this on its own?

No. It records a step after the step returns, so a step that was running when the process died runs again on recovery. It matched the hand-written journal task by task. It gives you at-least-once steps and a stable step id to build a key from.

What should an idempotency key be built from, and why not a random UUID?

From the run and the step: run id + tool call id, or workflow id + step number. It must be the same when the step is repeated after a crash. A random UUID made inside the step is new on every attempt, so the shop sees a brand-new write.

Why didn't 210 random kills find the double refund, and what do you do instead?

Most of a run is waiting on the model or a tool (176 of 210 kills landed there), and the gap is about 0.13 ms in a 2-second run: about one hit expected in 13,000 kills. Aim a kill right after each write on purpose, and keep random kills for the bugs you didn't think of.

Next up: Chapter 39 is about failures that never raise an error: an answer that breaks the rules, search that quietly returns the wrong pages, and a request that's too slow.

Learning something useful?

New labs, articles, and videos on evaluating and testing AI systems come out regularly. Follow along so you don't miss the next one.