SHUBHAM AI LAB
← All notes

7 October 2026|2 min read

Making human approval survive a restart

'Human in the loop' is easy to say. The hard part is the loop: what happens to a paused agent when the server restarts?

  • LangGraph
  • Human-in-the-loop
  • Reliability

When an AI agent drafts something and pauses for a person to approve it, what happens if the server restarts while that person is at lunch? If the answer is 'the case is gone', or worse, 'it carries on without approval', then the human in the loop is decoration.

Pausing is not the same as persisting

The naive version keeps the case in memory with an awaiting_approval flag. It works until the first deploy, crash or reboot. I wanted two guarantees: the model cannot skip the gate, and the wait survives the process dying.

Make the approval part of the graph

In my Case-Routing Orchestrator the approval is a LangGraph interrupt() inside the graph. Execution stops there and can only continue when something resumes the same thread with a decision. Because the pause is part of execution and not an instruction in a prompt, the model can't talk its way past it.

  1. Understand
  2. Plan
  3. Run agents
  4. Critic
  5. Draft reply
  6. Wait for a human
  7. Done
The critic can send the case back to Plan (bounded). Only a human decision moves it past the gate.
from langgraph.types import interrupt, Command

def approval_gate(state):
    decision = interrupt({"draft": state["draft"]})   # the graph pauses here
    return {"decision": decision}

# Later, possibly after a restart, on the same thread:
graph.invoke(Command(resume="approved"),
             config={"configurable": {"thread_id": case_id}})
Illustrative and simplified.

Durability: checkpoint every step

Every step is checkpointed to SQLite through LangGraph's AsyncSqliteSaver, keyed by the case's thread ID. A paused case lives on disk, not in memory. Resuming means loading that thread's checkpoint and delivering the decision.

Two stores, two jobs

I kept the checkpoint store (the graph's internals, used to resume) separate from a live tracker for business status (SQLite in WAL mode, so the status API updates the instant an agent starts or finishes). One is for correctness and one is for responsiveness. Mixing them makes both worse.

Guard rails around the gate

  • The approval endpoint returns 409 unless the case is actually waiting for a human, so you can't approve something that isn't waiting.
  • A reviewer can approve or request changes. The revision loop is bounded (two revisions), and a case that still isn't approved fails explicitly instead of looping forever.
  • The critic's re-planning is bounded too (three iterations), so an evidence loop can't run away in time or cost.

How I tested it

Kill the orchestrator while a case is WAITING_FOR_HUMAN, restart it, and check the case is still waiting with its state intact. It was. It's a manual test today, and the first thing I'd automate next.

The same shape, smaller

Opspilot, my small-distributor back office, uses the same pattern: each proposal pauses at interrupt(), state is saved in Postgres so it survives a restart, the owner decides, and the decision is applied and written to an audit log. Different business, same guarantee.

More notes