The Purchase Completed. There Was No Order.
A supervised purchase ran end to end. The agent built the cart. The cart mandate went to a human for approval, and the human approved it. The flow resumed, the final agent wrote its summary, and the run finished. No error. No retry. No warning in the log.
The order view in the UI said order not available.
The run’s list of API parts showed two. A purchase like that normally shows seven to nine. Everything after the approval (signing, completing the checkout, analysing the result) had not happened. Every signal the system gave said it had.
How both were true at once
The flow is a small tree of agents with a router at the top. On each turn the router decides which branch should handle the request. That decision lived in per-invocation state, in memory, and was never persisted.
A human-in-the-loop pause hands control back to the client. That is what a pause is: the server stops, the human thinks, and some time later a new request resumes the invocation. On resume, the process rebuilds that per-invocation state from what was saved. The router’s decision was not saved. So the router woke up with nothing, looked for the label it had stashed, found none, and declined to guess. It escalated.
Escalating was the correct behaviour for a router that genuinely does not know where to send a request. It was the wrong behaviour here, because the router had known, one pause earlier. The whole post-approval branch was skipped, and everything downstream carried on. The summarising agent at the end of the flow had a conversation to summarise, so it produced a plausible summary. The run reached its end, so it was marked complete.
Nothing raised. It returned.
The part that made it hard to see: this router had recently replaced the framework’s own handoff primitive. The framework’s version reconstructed the position in the tree from persisted history on every resume, so it had never had this problem. Swapping it out dropped that property, and there was no line in the diff that said so. The property was never written down anywhere, so removing it didn’t look like removing anything.
Our first read was that the failure was masked. That was wrong, and the correction mattered: masked implies cosmetic. This skipped the entire part of the purchase where money moves.
The class, not the incident
Every system with a pause has two kinds of state: the kind that survives the pause and the kind that doesn’t. The boundary between them is almost never written down. It lives in whatever the framework happened to persist, and it moves when you replace a framework piece with your own.
That is why this failure travels well beyond agents. A checkout flow, an approval workflow, a long-running job that waits on a webhook: each has a decision made before the wait and an assumption that the decision is still there after. When it isn’t, the safe-looking default (escalate, skip, fall through) produces a run that completes and does nothing.
The most expensive failures in this whole stretch of work did not throw. They finished.
What changed
The decision is now a record, not a variable. When the router chooses a branch, it emits that choice as an event in the session’s persisted history: the route, the basis for it, and how it was decided. On resume, the router reads the route it already took in this invocation and continues down it, instead of deciding again.
Recover, don’t re-decide. On a resumed turn there is often nothing new to classify (the turn carries the human’s approval, not a fresh request), and a fresh classification could disagree with the original. A run that changes its mind halfway through a purchase is worse than one that stops.
The tests pin the resumed reality, not the happy path. There is now a test that fails if a resumed invocation re-decides instead of reusing the route it already took. Its route function deliberately returns nothing, because that is what resume looks like in production. It fails if the branch did not run after the pause, and it fails if the router escalated on resume. Alongside it:
- a test that fails if recovery re-decides even when a fresh classification would pick a different branch;
- a test that fails if a route from a different invocation, or a different router, is picked up as this one’s;
- a test that fails if a recovered route is trusted after the route table changed underneath the paused run.
And one caveat stays attached to the green. The full suite passed at the end of that day: thousands of tests, zero failures. It passed on the older model tier we had reverted to while isolating the bug, not the newer one we intended to ship. That suite says nothing about the configuration it didn’t run, and the day’s record carries that as its first caveat, next to the green number.
The line that stays
A run that ends is not a run that worked. The only evidence that a purchase happened is the order, and the only evidence that a router remembered its decision is the record of the decision, read back after the pause.
“It completed” is not “it worked.”