Skip to content
MCP Five

The GateProject #323 / 51

Pausing is an array

The agent's entire state is its messages array — so freezing it mid-run is one INSERT.

You have decided some tool calls need a human. Now you have to actually stop, and that turns out to be the part that shapes the whole design.

Part 1 · The obvious implementation, and why it fails in production only

A human might take five seconds to decide. Or five minutes. Or they wander off and come back tomorrow.

A serverless function gets about 60 seconds and then it dies.

So the obvious implementation:

ts
// ❌ works in dev, works in staging, dies in production
const decision = await waitForHumanClick(runId);
if (decision === "approve") await runTool();

…holds the HTTP connection open and awaits the click. It works perfectly on your laptop. It works for fast clicks in staging.

There is a second problem with holding the connection, and it is worse: the machine holding it can be recycled at any point. Even within the timeout, the thing you are relying on staying alive is not something you control.

Part 2 · So don't hold anything open. End the request.

Pause and resume across two HTTP requests
Read the grey band in the middle. Nothing is running. There is no process, no held connection, no timer — the agent is a row in Postgres, and it will sit there indefinitely at no cost. A completely different request, possibly on a different machine, picks it up.

Part 3 · Why this is so little code

Because of something project #2 established for a completely different reason:

The agent loop's entire state is the messages array. There is no hidden memory, and no session on Anthropic's side.

So persisting an agent is persisting an array.

ts
// pause: the loop stops and hands you its state
yield { type: "approval_required", calls: pending, messages };
 
// ...one jsonb column later...
 
// resume: read it back, append the results, call the loop again
const messages = [...run.messages, toolResultMessage(results)];
runAgentLoop({ messages, toolbox, iterationOffset: run.iterations });

There is no continuation, no serialised generator, no coroutine library, no workflow engine.

The schema is not exotic either

ColumnHolds
iduuid, the run
statusrunning / awaiting_approval / done / error
messagesjsonb — the agent's entire mind
iterationshow many trips it has already taken
pending_callsjsonb, what it wants to do

One row. The interesting column is messages, and it is doing double duty: it is the pause mechanism and, for free, a complete record of the conversation as the model saw it — which is most of what replay needs.

Part 4 · The seatbelt that unbuckles itself

One detail, very easy to miss, that costs real money.

ts
runAgentLoop({ messages, toolbox, iterationOffset: run.iterations });

Without iterationOffset, iteration starts at 0 again on every resume. A run that pauses nine times gets nine fresh budgets of ten iterations — so the cap you thought was 10 is actually 90.

A seatbelt that unbuckles itself every time you stop the car is not a seatbelt.

And nothing fails. There is no error, no warning, no failed test. The symptom is a bill.

Part 5 · Two smaller things the pause needs

Nothing in the batch runs

If the model asks for three tools and one of them is gated, all three wait.

Running the two safe ones first would be faster. It would also mean that clicking Deny leaves you in a world where half the batch already happened — so the thing you refused did not occur, but its two siblings did, and the agent's next step is reasoning about a partially-applied state nobody chose.

Double-clicking Approve must not run the tool twice

For smash_jar that is harmless. For charge_card it is not.

The resume route checks the run's status and returns 409 Conflict on the second attempt. The state machine is the idempotency key:

sql
update runs set status = 'running'
 where id = $1 and status = 'awaiting_approval'
 returning *

If that returns no row, somebody already resumed it. Same trick as project #1's cookie-eating statement — the guard and the write are one indivisible operation, so two clicks cannot both pass the check.

Part 6 · Why Postgres, and why the serverless driver

Why a database at all? Because you need a durable place for one object that has a status. A run is a row; approving it is a conditional update; the whole concurrency story is where status = 'awaiting_approval'. Reaching for a queue or a workflow engine buys a lot of machinery to solve a problem the database already solved.

Why @neondatabase/serverless rather than pg? Because a connection pool is a great design for a server that boots once and runs for months, and a serverless function is the opposite. "Open a pool at boot" becomes "open a pool per request", and a traffic spike walks straight into:

terminal
FATAL: sorry, too many clients already

The serverless driver holds no connection at all — every query is an ordinary HTTPS request. No pool, no sockets to leak, works unchanged in a route handler or a script.

What you now know

  • Holding the HTTP connection open works in dev and fails in production for exactly the decisions people think hardest about.
  • The agent's whole state is its messages array, so pausing is an INSERT and resuming is a function call with a bigger array.
  • The resumed loop cannot tell it was paused — which is why this needed no continuation library.
  • Re-scope every counter when you split an operation across requests. iterationOffset is not optional.
  • Gate the whole batch, and make resume idempotent with a conditional update returning 409.
  • Serverless needs a driver that holds no connections — and that costs you interactive transactions, which you replace with single indivisible statements.