Engineering research / system design

Give Agent Actions an Undo Window Without Losing Execution Safety

Approving every agent action up front turns people into a queue. Scheduling the action and letting someone cancel it for a short window keeps a person in the loop at lower cost, if cancel and execute cannot both win, the window matches who is watching, and irreversible parts are held back.

By Sandeep Belgavi · Published 2026-11-03

Agent-era architecture · Practical technical guide · 3 November 2026

Approving every agent action up front turns people into a queue. Scheduling the action and letting someone cancel it for a short window keeps a person in the loop at lower cost, if cancel and execute cannot both win, the window matches who is watching, and irreversible parts are held back.

Approval queues that nobody reads

The returns agent at Belgavi's Book Store drafts replies, sends return labels and issues refunds up to a small amount. When it launched, every action waited for a person to approve it. Within a month Tanishka, who ran the desk, was approving about four hundred actions a day, and the approval screen had become a button she pressed after reading the first line. The control still existed on paper, but nobody was actually checking the actions any more.

An undo window changes the default. The agent's action is scheduled to execute a few minutes later, it shows up in a feed with a Cancel button, and it runs unless someone cancels it. People spend their attention on the actions that look wrong instead of clicking through all of them. Gmail's Undo Send is the familiar small version: a cancellation period of 5, 10, 20 or 30 seconds after you press Send.

The state machine

StateEntered whenLeaves to
scheduledthe agent proposes the action; execute_at = now + windowcancelled, executing
cancelleda person or a rule cancels before execution startsfinal
executinga worker claims it at or after execute_atdone, failed
donethe downstream call succeededfinal; only compensation from here
failedthe downstream call failed permanentlyfinal; reported to the case owner

Everything interesting happens at the boundary between scheduled and the two states it can move to. A cancel that arrives one millisecond before the worker claims the action must win; one that arrives after must lose, clearly, and tell the person what to do instead.

Cancel versus execute: exactly one wins

Both transitions must be conditional writes on the same row, so the database decides the winner. A cancel is UPDATE ... SET state = 'cancelled' WHERE id = $1 AND state = 'scheduled'; a claim is the same with state = 'executing' and execute_at <= now(). Whichever commits first changes the state, and the other matches no row. The PostgreSQL documentation explains why this holds under Read Committed: the second updater waits for the first, then re-evaluates its WHERE clause against the new row version.

Python over SQLite, from the companion example; on PostgreSQL the inner SELECT would add FOR UPDATE SKIP LOCKED so several workers do not contend for one row.

def cancel(conn, action_id, who):
    row = conn.execute(
        "UPDATE agent_action SET state = 'cancelled', cancelled_by = ? "
        "WHERE id = ? AND state = 'scheduled' RETURNING id", (who, action_id)).fetchone()
    if row:
        return {"result": "cancelled"}
    state = conn.execute("SELECT state FROM agent_action WHERE id = ?", (action_id,)).fetchone()[0]
    return {"result": "too_late", "state": state,
            "next_step": "use the compensating action for this kind of effect"}

def claim_due(conn, now):
    return conn.execute(
        "UPDATE agent_action SET state = 'executing', claimed_at = ? "
        "WHERE id = (SELECT id FROM agent_action WHERE state = 'scheduled' AND execute_at <= ? "
        "ORDER BY execute_at LIMIT 1) AND state = 'scheduled' RETURNING id, kind, payload",
        (now, now)).fetchone()

Two mistakes break this. Reading the state, deciding in application code and then writing unconditionally lets both sides win. And checking the clock in the cancel path (refusing a cancel because execute_at has passed) creates a gap where the worker has not claimed the action yet but the cancel is refused anyway, so neither side acts until the next poll. Let the state, not the clock, decide.

Effects that cannot wait, and effects that cannot be undone

Not every action can sit in a queue. Reserving the last copy of a book must happen now or the copy is sold to someone else. Split such actions: the reversible part (a reservation that expires) executes immediately, and the irreversible part (charging, emailing the customer) waits in the window. Cancelling then means releasing the reservation, which is cheap.

The window protects only what happens after it. Once a refund has gone to the payment provider or an email has left the mail server, cancel becomes compensation: a reversal, a correction email. Write that path for each action kind before launch, because a too_late result with no next step leaves the person stuck.

How long the window should be

The window has to be longer than the time it takes the people who are supposed to catch mistakes to see the action. If the desk checks the feed every 15 minutes, a 2-minute window catches almost nothing, and the extra 2 minutes of latency are paid for nothing. Measure it: log when each scheduled action was first viewed and compare the distribution with the window. Customer-facing confirmations change too: tell the customer the refund is scheduled, not done.

A per-kind window works better than one global value. A return label is cheap to reissue and can use a short window. A refund above a threshold, or any action on an account that has been flagged, can wait longer or fall back to explicit approval.

What stays deterministic

  • Which action kinds may use the window and which still need approval, from a table, not from the model.
  • The window length per kind, the clock used and the time zone in which it is displayed.
  • The conditional transitions and the worker claim.
  • The executor, which calls the downstream API with an idempotency key derived from the action id, so a worker that crashes after the call and is replaced does not refund twice.
  • Cascades: when an action is cancelled, any scheduled action that depends on it is cancelled too, in the same transaction.

Migration steps from pre-approval

  1. Record, for a few weeks, which approvals were rejected and how long each waited. Rejected kinds with a clear pattern can become rules; kinds that are almost never rejected are candidates for the window.
  2. Add the scheduled state and the feed, but keep approval on: show what would have executed and when.
  3. Move one low-risk kind, such as return labels, to the window. Watch view times and cancel rates.
  4. Add compensation for each kind before moving it, and test the race with concurrent cancel and claim calls.
  5. Keep explicit approval for irreversible, high-value or first-of-a-kind actions.

When this is unnecessary

If the action is easy to reverse later anyway, such as a soft delete or a draft, delaying it adds latency without adding safety. If the action must be immediate for the customer, such as a password reset, a window is the wrong tool. And if nobody will look at the feed within the window, it is only a delay with a Cancel button nobody presses; fix the review process first, or keep approval for the risky kinds.

Verification

The companion example runs a cancel and a claim against the same scheduled action from two threads, many times, and asserts that exactly one succeeds in every trial; it also checks that a crashed executor's replacement reuses the idempotency key. In production, track the cancel rate per kind, the share of cancels that arrived too late, and the time from scheduling to first view.

Sources and scope

Checked against these sources on 3 November 2026. The approval volume in the opening is an illustrative scenario. Race outcomes come from the companion example on SQLite; nothing here is a production measurement.

Schedule low-risk agent actions and let people cancel them for a window instead of approving each one. Make cancel and execute conditional writes on one row so exactly one wins, run the reversible part now and hold the irreversible part, and size the window to how quickly someone actually looks.

Views are my own and do not represent any employer, past or present.