You added a human-in-the-loop gate to your agent. Before it moves money, deletes a customer, or emails a thousand people, it stops and asks a person. Good instinct. Then you followed the tutorial, and the tutorial handed you a time bomb.
Here is the shape of it. Your agent pauses, cleanly, waiting for approval. The approval will come in an hour — maybe tomorrow morning when someone reads the notification. And somewhere in that window you ship a deploy, or the autoscaler recycles the container, or the box just crashes. When it comes back, the pending approval is gone. Not "errored" — gone, along with the entire run that was waiting on it. The human clicks approve into the void.
The bug isn't the approval. It's where the pause lives.
A human approval is your longest await#
Everything else your agent does returns fast. A tool call is seconds. A model completion is seconds. But a human approval is minutes to days — it waits on a person's attention, which is the slowest dependency in the system.
That matters because restarts happen on the scale of hours. Deploys, scale-downs, crashes. So of every await in your agent, the approval is the one all but guaranteed to straddle a restart. Short awaits survive by luck. The approval await is the one that doesn't — and it's the one you least want to lose, because it guards the consequential actions.
The approval is the one place in your system you cannot keep state in memory. It is also, in every tutorial, the one place that does.
What interrupt() actually promises — and what it doesn't#
LangGraph's human-in-the-loop primitive is interrupt(). You call it inside a node with the payload a human needs to see; the graph pauses and surfaces it. Later you resume:
from langgraph.types import interrupt, Command
def approve_refund(state):
decision = interrupt({ # pauses here, waits for a human
"action": "refund",
"amount": state["amount"],
"account": state["account"],
})
if decision != "approve":
return {"status": "rejected"}
return {"status": "approved"}
# ...somewhere else, when the human answers:
graph.invoke(Command(resume="approve"), config={"configurable": {"thread_id": tid}})
Command(resume="approve") makes "approve" the return value of the original interrupt(), and the node continues. Clean. But read the mechanism: the run can resume only because a checkpointer wrote the paused state down against thread_id. The resume is a database lookup wearing a function call.
And the default checkpointer is InMemorySaver. It writes the state to a dictionary in the process. Restart the process and the dictionary — your pending approval — is garbage-collected. The API looks durable. The storage isn't.
Two ways to make the pause outlive the process#
Option 1 — a persistent checkpointer. If you're already on LangGraph, this is the smallest possible fix: swap the saver. Same interrupt() code, but now the paused run is a row in Postgres.
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string(os.environ["DATABASE_URL"])
graph = builder.compile(checkpointer=checkpointer) # was InMemorySaver()
A fresh process — a new deploy, a restarted pod — reads the same thread_id and resumes exactly where it stopped. The wait is now backed by a database, so it survives anything the database survives.
Option 2 — a durable-execution engine. When the wait is long and it's one of many — retries, timeouts, several approvers, compensation on rejection — move the whole thing into a runtime built for it. Durable-execution engines like Temporal, DBOS, and Restate model "await a human signal for three days" as a first-class, persisted operation. The engine owns the pause, the retries, and the recovery; your code reads like it's blocking on a function even though the process underneath it died and came back twice.
The tradeoff table:
| Where the paused approval lives | Survives a restart? | Use it when |
|---|---|---|
| InMemorySaver (default) | No — dies with the process | Local dev, demos, tests only |
| Persistent checkpointer (Postgres/Redis) | Yes — paused run is a DB row | You run LangGraph and want the smallest durable change |
| Durable-execution engine (Temporal/DBOS/Restate) | Yes — the wait is a durable signal | Long waits, fan-out, retries; one runtime owns it |
The one discipline that keeps it correct: idempotent resume#
Durability creates a new failure mode. Once the state is persisted, resumes get retried — the webhook fires twice, a queue redelivers, you re-invoke after a network blip. If the code after the interrupt executes the approved action every time it runs, a durable approval becomes a double refund.
So tie the side effect to the approval id, not to "did we reach this line":
if not already_executed(decision.approval_id): # dedupe on the id
issue_refund(state["account"], state["amount"])
mark_executed(decision.approval_id)
This is the same idempotency discipline every tool call needs. The approval path just makes getting it wrong expensive enough to notice.
Where to stop#
You don't need a distributed-systems rewrite. You need to move exactly one thing — the pending approval — off the process heap and into something that survives a restart. If you're on LangGraph, that's a one-line saver swap plus an idempotency check. If your waits are long and plural, it's a durable-execution engine.
Either way, the test is blunt: kill the process while an approval is pending, bring it back, and click approve. If the action still fires exactly once, your human-in-the-loop is real. If nothing happens, you built a gate with no wall behind it. (For a related how-to on wiring the human's answer back to a paused agent by email, see our Resend approval-queue pattern.)



