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.
On this page
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:
- Overwrite on arrival. The newest result becomes active.
- 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.
- 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
| Policy | Active value correct | Value and receipts correct |
|---|---|---|
| Overwrite on arrival | 3 of 11 | 1 of 11 |
| Check, then write later | 10 of 11 | 10 of 11 |
| Check at the write | 11 of 11 | 11 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.
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.
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.
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
- Bind every job to the decision it was started for, and carry that ID back with the result.
- Order decisions by what they replace, not by timestamps.
- Admit a result only if its decision is still current, checked at the write, where your storage enforces it.
- Keep every result you receive. Mark it superseded, cancelled, duplicate or rejected; don’t delete it.
- Treat a reused completion ID with different content as an error, not an update.
- Show competing corrections as a conflict and require an explicit choice.
- Keep a deliberately broken guard in your tests so you know the race is detectable.
- 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.