Skip to content

When the agent finishes yesterday's task

What should a product do when an async agent's result arrives after the user changed or cancelled the goal? A deterministic demo with 11 synthetic schedules and three admission policies, the race a pre-check misses, and the wording for current, pending, cancelled and conflict states.

Tim Urista 8 min Stale-completions demo
Title card reading: When the agent finishes yesterday's task. Scores on 11 synthetic cases: overwrite 3 of 11, check then write 10 of 11, check at the write 11 of 11. On the right, the check/write race end state from trace.json: overwrite on arrival and check-then-write both show the hotel current as c1 Hotel Alfama, Lisbon, in rose; check at the write shows the hotel pending with c1 kept as superseded by h2, in teal.
On this page
  1. Where the question came from
  2. One itinerary, three admission policies
  3. Overwrite on arrival got 3 of 11 right
  4. The broken guard fails exactly once, and that’s the point
  5. Conflict is a state, not a tiebreak
  6. Where it stops helping
  7. What each state should say
  8. Eight checks before you ship async results

At a glance

  • Overwrite-on-arrival showed the right active value in 3 of 11 authored cases: it let a late result for a replaced goal win, revived a cancelled search and let a reused completion ID replace content.
  • Checking that the result's goal is still current before writing fixed 10 of 11 cases. It failed the one where the user corrected between the check and the write.
  • Checking at the write, in one non-yielding step, got all 11 right in the local model; a real multi-writer system needs that condition enforced at its storage boundary.
  • Two corrections to the same goal became a visible conflict with no active value until an explicit resolution, instead of a winner picked by finish time.
  • A correction the authority hasn't received can't block a stale result. The model admitted it, then dropped back to pending when the correction arrived, and the product has to say so.

You ask an assistant to find a hotel in Lisbon. A minute later you change your mind: Porto. The Porto search finishes first. Then the Lisbon search, which nobody cancelled at the worker, finishes too, and the itinerary now says Lisbon.

Nothing crashed. Every worker did its job. The product just treated “the worker finished” as if it meant “this result still applies,” and those are different questions. Async assistants answer the first one well and can skip the second.

I wanted to see how much of this a small, explicit admission rule fixes, where it stops helping, and what the screen should say in each case. So I wrote down eleven schedules and the outcome each one should have, froze them, and only then had three policies built and run against them.

Where the question came from

It started in a Medium thread. Andrii Tkachuk proposed avoiding ingestion-time last-write-wins for agent memory: separate event time from consolidation time, version memory per scope and key, keep superseded records, and resolve an active view. That’s a proposal, not a measured system, and the demo below is not his implementation. My reply asked about the case a timestamp can’t settle: two corrections to the same key that don’t know about each other.

Three LinkedIn discussions pushed the same way from the product side. On Lisa L.’s piece about goals for AI, I argued that a stop request should end the mission even if follow-ups remain. On Najmi Mansoor Ahmed’s proactive agents article, I suggested testing a changed plan that reaches only part of the system. And on Bahni M.’s agent-building notes, I argued that a stop deserves its own visible state instead of counting as success. None of those authors has reviewed or endorsed what follows.

None of this is new computer science. Version checks, conditional writes and “409 Conflict” are old tools; the system in my TigerMill write-up already returns a 409 on stale writes. What I hadn’t seen spelled out is the product side: what each outcome should look like to the person who changed their mind.

One itinerary, three admission policies

One fictional itinerary with two fields, hotel and flight. Every job is started against a decision, the user’s goal at that moment. When the user corrects or cancels, the new decision names the decision it replaces. Order comes from those links. Timestamps decide nothing: clocks on different devices can’t tell you which change came first, so they can’t break the tie.

Three policies get the same schedules:

  1. Overwrite on arrival. The newest result becomes active.
  2. Check, then write later. When a result arrives, check that its decision is still current, then write. The write happens after a yield, without rechecking. This is the bug you get when the check and the write are two calls.
  3. Check at the write. Compare the result’s decision with the current one and update the view in one non-yielding step. Results for replaced decisions are kept as superseded, results for cancelled goals as cancelled, and two competing decisions produce a visible conflict until someone resolves it.

The eleven cases cover a plain completion, a correction made before any result arrives, an old result that lands after the new one, cancellations before and after, duplicate delivery, a reused completion ID with different content, the check/write race, independent keys, competing corrections, and a correction the authority hasn’t heard about yet. Ticks are integers that order events. They are not latency.

The fixtures and expected outcomes were hashed before the policy code existed (frozen-hashes.txt). Every policy matched its predicted behavior on the first run, as recorded in the repo. The code, fixtures and results are on GitHub, and you can step through every case side by side in the live stepper.

Overwrite on arrival got 3 of 11 right

PolicyActive value correctValue and receipts correct
Overwrite on arrival3 of 111 of 11
Check, then write later10 of 1110 of 11
Check at the write11 of 1111 of 11

Source: results.json, 11 authored synthetic cases. “Active value correct” was added as a second column after the first run, because overwrite-on-arrival has no receipt labels at all and scored 1 of 11 partly for that reason.

Overwrite on arrival got the active value right only when the newest result happened to be the right one: a plain completion, a correction whose result arrived last, and a duplicate of the same content. It let the Lisbon result win in the out-of-order case. It made a cancelled hotel search active again. It let a reused completion ID replace the content. In the conflict case, it picked whichever correction finished last.

Four boxes in a row: job j1 starts for goal h1 (hotel in Lisbon); the user changes the goal to h2 (hotel in Porto); the old job finishes and its result c1 arrives after c2; the admission decision asks whether h1 is still the goal and keeps c1 as superseded. Below, a note says overwrite on arrival would make Lisbon the active hotel.
The worker finished. Does the result still apply? Admission compares the result's decision with the current one.

The broken guard fails exactly once, and that’s the point

Checking before writing fixed ten of eleven cases. The one it missed is the race:

check, then write later           check at the write
tick 3  check: h1 is current      (no separate check)
tick 4  user corrects: h2         tick 4  user corrects: h2
tick 5  write c1 → active         tick 5  c1 made for h1 → superseded

From trace.json, case check-write-race.

The check was correct when it ran. The write landed one tick later on a goal that no longer existed. That gap is invisible in a code review that sees if (isCurrent(result)) save(result) and moves on. It only shows up if the check and the write can be separated, which is why the test keeps the broken version as a control. A test suite where nothing can fail this way can’t tell you whether your guard works.

Three columns. Overwrite on arrival and Check then write later both show the hotel as current with c1 Hotel Alfama, Lisbon, outlined in rose as differing from the expectation. Check at the write shows the hotel pending, with c1 listed as superseded.
Check/write race, end state. The guard checked at tick 3, the correction landed at tick 4, the write at tick 5 made Lisbon current. Checking at the write keeps it superseded. A rose outline marks a view that differs from the frozen expectation.

In the local model, “one non-yielding step” is a Python function with no await in it. That proves nothing about a real system. With several writers, the condition has to live where the write happens: a conditional update in the database, a compare-and-set, a version column. The check in application code is a courtesy. The check at the storage boundary is the guard.

Conflict is a state, not a tiebreak

The competing-corrections case is the one my Medium reply asked about. Two corrections replace the same decision, say one from your phone and one from your laptop. Each starts a job. Both finish.

Overwrite on arrival shows whichever finished last. The corrected policy shows neither: the hotel is in conflict until an explicit resolution names one. After the resolution, the chosen result becomes current and both results stay in history.

Three columns at tick 7. Overwrite on arrival shows the hotel current as cb Hotel Aeroporto. The other two columns show the hotel in conflict, no active value until resolved, with both results ca and cb listed as held-conflict.
Two corrections replaced the same decision. Overwrite on arrival shows whichever finished last; the other two policies show a conflict with no active value.

Showing nothing feels worse than showing something. I’d still rather the screen say “You changed the hotel twice. Which one?” than make the airport hotel the answer because that search finished second.

Where it stops helping

The last case is the honest one. The user tells a different assistant to switch to Porto, and that change hasn’t reached the authority yet. The Lisbon result arrives, matches everything the authority knows, and is admitted. When the correction finally arrives, the view drops back to pending and the Lisbon result is recorded as admitted at the time.

That’s the expected limit, and it’s in the results file as one. An admission rule can only use what the authority knows when the result arrives. It can’t prevent a stale result that arrives before the change does. The test I suggested under Najmi’s article, a changed plan that reaches only part of the system, isn’t whether the guard catches it. It’s whether the product notices later and says so.

Two more limits belong next to that one. Cancelling in the view doesn’t prove the worker stopped; in the cancel-before case, its result still arrived after the cancellation. And cancelling after a result was admitted doesn’t undo anything that result already did outside the app.

What each state should say

The active value had four states in the model: current, pending, cancelled and conflict. Superseded is a receipt label, not a state of the view. Here’s the wording I’d start from:

  • Current. Show the result. No badge needed.
  • Pending. “Updating for your new request (Porto).” Not a spinner over the old answer.
  • Superseded. Not shown as the answer. Kept in history: “Lisbon search finished after you switched to Porto.”
  • Cancelled. “You cancelled the hotel search. One result came back afterward; it wasn’t applied.” Don’t say “stopped” unless the worker confirmed it stopped.
  • Conflict. “You changed the hotel twice. Pick one.” Both options visible, neither pre-selected by time.

This is the point of my comment on Bahni’s article: a cancelled or conflicted task is unresolved work. It shouldn’t vanish, and it shouldn’t count as done.

Eight checks before you ship async results

  1. Bind every job to the decision it was started for, and carry that ID back with the result.
  2. Order decisions by what they replace, not by timestamps.
  3. Admit a result only if its decision is still current, checked at the write, where your storage enforces it.
  4. Keep every result you receive. Mark it superseded, cancelled, duplicate or rejected; don’t delete it.
  5. Treat a reused completion ID with different content as an error, not an update.
  6. Show competing corrections as a conflict and require an explicit choice.
  7. Keep a deliberately broken guard in your tests so you know the race is detectable.
  8. Test a correction that arrives after the old result, and decide what the product says when it does.

If you’re working through how your own assistant should behave when users change their minds, here’s how I work.


AI-assisted, human-reviewed.

How this was made: the scenarios and expected results were written and hashed before any policy code. Everything runs with the Python standard library, and the review page and diagram are rendered from the result files. Run it yourself from the repo, or step through every case in the interactive trace.

ai-agentsasyncproduct-designconsistency

Questions or feedback?
I reply to every note.

Say hi