Ashvara
Blog/AI
AI

Build a crash-proof AI agent in Postgres, then crash it

We built a small agent that saves every step to Postgres, then killed it mid-run. Checkpoints alone still placed the order twice. Here's the fix, with code.

S
Sahil Jain
AI · Ashvara
Oct 26, 2026
9 min read
Crash-proof agents

To make an AI agent survive a crash, save each finished step to a table and skip saved steps when it runs again. That stops repeated model calls, but on its own it doesn't stop repeated side effects. When we killed our test agent after it had placed an order but before it had saved that step, the re-run placed the order a second time. The fix is an idempotency key: an ID for the step that the system taking the action can recognise. With both in place, a crashed and resumed run ended in exactly the same state as one that never crashed. Each checkpoint cost about 0.6 ms.

Diagram showing a six-step grocery reorder agent that crashes at step 4, under two designs. On the left, an amber panel labelled "start again from the top" shows a first run of four steps: 1 plan (a paid model call), 2 pantry, 3 choose (a paid model call) and 4 order placed, followed by a red crash starburst. A red dashed arrow labelled "nothing was saved, so begin at step 1" leads to a re-run of all six steps, in which plan and choose call the model again and the order is placed again. A red pill reads "5 model calls, 2 orders: the customer pays twice", captioned "Most agent loops keep their progress in memory. A crash, a deploy or a timeout throws it away. The paid calls and the side effects happen again." On the right, an indigo panel labelled "resume from the journal" shows a first run in which steps 1 to 3 are ticked as saved and step 4, the order, crashes before it is saved. A database icon labelled agent_steps holds steps 1, 2 and 3 with their output. On the re-run, steps 1 to 3 are grey and marked replayed, step 4 runs again with the same key, and the order store answers "already have run-1:4". Steps 5 and 6 then run. A green pill reads "3 model calls, 1 order, 1 email: as if it never crashed", captioned "The journal skips what's done. The key stops repeats." Stat chips across the top read: 0.6 ms per checkpoint on local Postgres; checkpoints alone, still 2 orders; with keys, 3 model calls, 1 order, 1 email. A footer band compares three cases: a crash after step 4 was saved, where checkpoints alone are enough; a crash after ordering but before saving, where checkpoints alone give 2 orders; and checkpoints plus idempotency keys, which give 1 order at either crash point. It closes with: a checkpoint records that a step finished; only the thing being changed can know it already happened.

We've written before about why agents belong in durable background workflows. This post is the hands-on version: about 65 lines of Node and one Postgres table, then a deliberate crash at the worst possible moment to see what actually holds up.

The agent

The test agent reorders groceries in six steps: plan the order (model call), check the pantry, choose what to buy (model call), place the order, send a confirmation email, and summarise (model call). We stubbed the model so we could count exactly how many times it was called; durability doesn't depend on which model you use.

The journal is one table, keyed by run and step number:

create table agent_steps (
  run_id text  not null references agent_runs(id),
  step   int   not null,
  name   text  not null,
  output jsonb not null,
  primary key (run_id, step)
);

Every step goes through one function. If the step already has a row, it returns the saved output without running anything. If not, it runs the step and saves the result. The two crash() lines are how we killed the process at chosen points:

// Run each step at most once per run. On a re-run, finished steps return their saved output.
export async function step(runId, n, name, fn) {
  if (MODE !== "naive") {
    const { rows } = await db.query(
      "select output from agent_steps where run_id = $1 and step = $2", [runId, n]);
    if (rows.length) return rows[0].output;
  }
  const key = MODE === "durable" ? `${runId}:${n}` : null;   // idempotency key
  const output = await fn(key);
  crash(`after-effect:${n}`);
  if (MODE !== "naive") {
    await db.query(
      "insert into agent_steps (run_id, step, name, output) values ($1, $2, $3, $4)",
      [runId, n, name, JSON.stringify(output)]);
  }
  crash(`after-checkpoint:${n}`);
  return output;
}

The agent itself reads like an ordinary script, which is the point. Each line is a step that runs at most once:

const plan    = await step(runId, 1, "plan",    () => model(runId, 1, "plan this week's order"));
const pantry  = await step(runId, 2, "pantry",  async () => ({ low: ["atta", "toor dal", "paneer", "spinach"] }));
const list    = await step(runId, 3, "choose",  () => model(runId, 3, `order for ${pantry.low}`));
const order   = await step(runId, 4, "order",   (key) => placeOrder(pantry.low, key));
const email   = await step(runId, 5, "email",   (key) => sendEmail(`Order ${order.orderId} placed`, key));
const summary = await step(runId, 6, "summary", () => model(runId, 6, "summarise for the user"));

Step outputs have to be JSON, because they're stored as JSON. That's a useful constraint: anything a later step needs must be written down, not held in a variable that a crash would lose.

Where to crash it

There are two interesting places for step 4 to die:

  1. After the step was saved. The order exists and the journal knows it.
  2. After the order was placed but before the step was saved. The order exists, but as far as the journal is concerned, step 4 never ran.

The second is the one that matters. It's a window of a few milliseconds, but a deploy, an out-of-memory kill or a function timeout can land in it, and across thousands of runs some of them will.

We ran three versions of the agent against both crash points. Each crashed once and was then run again to completion:

Crash pointDesignModel callsOrdersEmails
After step 4 was savedNo journal521
After step 4 was savedJournal only311
After step 4 was savedJournal + idempotency keys311
After ordering, before savingNo journal521
After ordering, before savingJournal only321
After ordering, before savingJournal + idempotency keys311

A run with no crash makes 3 model calls, places 1 order and sends 1 email. Only the last design matches that at both crash points.

The missing piece: idempotency keys

The journal can only record that a step finished. It can't know whether a step half-finished, because the crash happened before it could write anything down. Only the system being changed knows whether the order already exists. So the step passes it a key that is stable across retries (run-1:4), and the order store refuses to create a second order with the same key:

async function placeOrder(items, key) {
  const { rows } = await db.query(
    `insert into orders (items, idempotency_key) values ($1, $2)
     on conflict (idempotency_key) do nothing returning id`, [JSON.stringify(items), key]);
  if (rows.length) return { orderId: rows[0].id };
  const existing = await db.query("select id from orders where idempotency_key = $1", [key]);
  return { orderId: existing.rows[0].id };
}

The Postgres documentation is plain about what ON CONFLICT DO NOTHING does: it "simply avoids inserting a row as its alternative action". The unique index does the real work. On a retry, the insert does nothing and the step returns the order it already made, so step 5 emails the right order number.

Most payment and messaging APIs support the same pattern. Stripe's API reference explains why it exists:

If a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice.

Note the fine print in that same page: keys can be removed "after they're at least 24 hours old". If a crashed run might sit for longer than that before it's resumed, check the step's effect directly rather than relying on the key alone.

What it costs

On our test machine (Node 20 and Postgres 14 on the same laptop), saving a new step took a median of 0.64 ms (1.34 ms at the 99th percentile), and replaying a saved step took 0.24 ms. Over a network to a managed database you'd add a round trip or two. A single model call takes far longer than either, so the journal is not where an agent's time goes.

Rules we'd follow

  1. One row per step, keyed by run and step number. The primary key is what makes "at most once" true.
  2. Every side effect gets a key derived from the run and step, never a fresh random ID, which would be different on the retry.
  3. If the side effect is in your own database, do it in the same transaction as the checkpoint. Then the crash window disappears entirely, with no key needed.
  4. Keep steps deterministic around the journal. If step order depends on the time of day or a random number, a re-run can line steps up against the wrong saved rows. Put anything variable inside a step, so it's saved.
  5. Test the crash, don't assume it. Our own earlier post said progress is never lost. That's true of the journal. It took deliberately killing the process to see that it isn't true of the order.

Our opinion

Checkpointing an agent is easy; making its side effects safe to repeat is the actual work. Most guides to durable agents stop at "save each step", and our test shows exactly where that falls short. The duplicate didn't come from the model or the framework. It came from a few milliseconds between two writes.

We'd also argue against reaching straight for a workflow engine. Engines are worth it for long-running, many-step, multi-service work. For an agent with a handful of steps that already uses Postgres, one table and one function get you most of the way, and you can read every line of it. Either way, the idempotency keys are still your job. No engine can make someone else's API safe to call twice.

How Ashvara helps

We build the backend that agents run on: the step journal, the idempotency keys on every action that costs money or sends a message, and the tests that crash it on purpose before production does.

That's the backend and API development side of our AI solutions work. If you have an agent that sometimes does things twice, or that you're nervous to restart mid-run, tell us what it does. The crash test above takes an afternoon, and it tells you where you stand.


Sources: our test agent (Node 20, PostgreSQL 14, stubbed model, each scenario run once from a clean database); Stripe API reference: idempotent requests; PostgreSQL documentation: INSERT and ON CONFLICT. Timings are from a local database and will be higher over a network.

Share this article
S
Sahil Jain

Founder at Ashvara, a studio that builds software end to end - mobile, web, AI, and the systems behind them. Writes about shipping products that last.

Building something? Let's talk.