LangGraph Shadow Mode: Prove the Agent Before It Spends Money
The question arrives in a budget review, dressed as a hypothetical: is the agent already able to email our contractors?
The question arrives in a budget review, dressed as a hypothetical: is the agent already able to email our contractors?
The most expensive component in an agent is not the model. It is the record you decided not to write. LangGraph's answer is a checkpointer compiled into the graph and keyed by the run's thread id, saving graph state between steps so a restarted run resumes from its last checkpoint instead of starting again (LangChain docs, persistence).
A guard test is proven only once you have watched it fail on a defect you planted on purpose — and the plant stays, so it fails again on every run. Every guard worth keeping asserts an absence: no node writes, every model node is guarded, every declared file ships. Absence has a pass path that has nothing to do with your code. The scan looked in the wrong directory, the pattern never matched, the node list it walked was empty.
A file a graph reads at run time is a deployment contract, not a repository detail. The container is the only place that contract gets tested, and nobody runs the tests there.
LangGraph hands every plain def node under ainvoke to the event loop's default thread pool — the same one asyncio.to_thread borrows from. Fill that pool with loops that never return and sync nodes queue behind them silently, no error, no timeout.
Take a session-level lock with pg_try_advisory_lock on a dedicated, unpooled connection, keyed by a stable hash of the graph name. The winner runs the tick; every other replica gets false at once and returns.
Five nights of shadow on zone A's pick lists. The second died part-way; the other four matched live line for line. Ask for four nights of agreement and the record fails — not because four nights disagreed, but because only three of them sit in a row.
The checkpointer restores state. It does not restore the world.
Wrap every model-backed node in a guard applied inside build(), so that whatever goto the node returns, the run goes to that node's checker and only the fields a model may write survive — the Command[Literal[...]] annotation is a drawing instruction, not a control.
A LangGraph thread id is the key the checkpointer stores a run's state under, so derive it from what the run is about — graph, mode, period, subject — and "run this" becomes "make sure this has run": a retry resumes the existing thread instead of starting a second.