An environment where the agent edits a shared JSON document while a scripted rival writes to the same document between the agent's steps. Unlike merge-conflict environments that hand the agent a static file with conflict markers, the conflict here happens live: the agent only learns about it through revision preconditions (update(..., expected_revision) fails with a 412 and the current revision) and has to re-read, merge and retry. An unconditional write is also on the menu, so the environment measures whether the agent chooses the precondition at all, not just whether it can merge.
When two writers read the same version of a document and each writes back its own copy, the second write silently erases the first. This is the lost update, and it shows up as soon as agents share a file, a memory store or a todo list. Position: Multi-Agent Systems Should Prioritize Concurrency Control (Yang, Li, Ji, Zhang and Jiang, ICML 2026; arXiv:2608.18092, ICML poster) lists it among the core hazards of multi-agent systems, and calls for benchmarks that vary contention and for training agents to be concurrency aware.
The fix is old and well understood. HTTP gives every representation an ETag; a client sends If-Match with the tag it read, and the server answers 412 Precondition Failed if someone else wrote first (RFC 9110, sections 8.8.3, 13.1.1 and 15.5.13). The MCP Filesystems Working Group's problem statements propose the same for MCP resources: an opaque revision returned by stat and read, writes conditional on it, and the current revision returned with the precondition error so the agent can re-read and retry. The protocol side is the easy part. What is missing is evidence that models use the precondition and repair their write correctly when it fails, and a place to train that skill. This environment is that place.
reset(seed, tier) builds a document from one of four families (service config, agent memory note, todo list, user settings) and an instruction such as "Increase stats.sessions by 2" or "Set api.timeout_ms to 3 times the current value of api.retry_interval_ms".update, write, create or delete on the document), the rival commits its next scheduled edit. Whatever the agent read is now stale.submit() or when the 16-call budget runs out, and the final document is graded.Concurrency is simulated, not real: rival writes are seeded and step-interleaved, not wall-clock races. The same seed gives the same document, task and rival schedule, and the same sequence of tool calls gives byte-identical results.
| tool | behavior |
|---|---|
stat(uri) |
{revision, size} |
read(uri) |
{content, revision}; content is JSON text |
update(uri, content, expected_revision) |
writes if the revision matches and returns the new one; otherwise {status: 412, error: "precondition_failed", current_revision} and nothing is written |
create(uri, content) |
fails with 409 if the URI exists |
delete(uri, expected_revision) |
conditional, 412 on mismatch |
write(uri, content) |
unconditional overwrite |
submit() |
ends the episode and returns the score breakdown |
Revisions are opaque strings compared exactly, like a strong ETag. Note the trap in the 412 response: it carries current_revision, so an agent can resend its stale content with the fresh revision and the server will accept it. That is still a lost update, and the grader treats it as one.
| tier | rival activity | what it tests |
|---|---|---|
| T0 | none | control: is the task itself solvable |
| T1 | one write to a different field, before the agent's first write attempt | detect the conflict, re-read, reapply |
| T2 | 2 to 4 writes to different fields, one before each of the agent's first k write attempts | retry loops and step budget |
| T3 | one write that overlaps the agent's intent: both bump the same counter, both append to the same list, the rival changes a value the agent's edit depends on, or the rival renames the key the agent must set | merge the intent, not the patch |
If tier is not given, it is drawn from the seed over T1 to T3.
Deterministic checks on JSON leaves, no LLM judge. With R the document after the rival writes that landed and E the result of applying the agent's intent to R:
task: share of the leaves the intent changes (E vs R) that the final document gets right.preserve: share of landed rival writes still present, times the share of untouched fields still intact.reward = task * preserve, in [0, 1].Doing nothing scores 0 (task 0). A blind overwrite from a stale read scores 0 on T1 to T3 (the rival's write is gone). Copying the rival's version back scores 0 (task 0). Writing only the requested field and dropping the rest scores 0. Lists compare as multisets. The breakdown also reports conflicts_seen, blind_overwrites, write_calls, update_calls and steps.
Scripted policies, 50 seeds per tier (uv run python baselines/run_scripted.py):
| policy | T0 | T1 | T2 | T3 |
|---|---|---|---|---|
| oracle: read, conditional update, on 412 re-read + reapply + retry | 1.00 | 1.00 | 1.00 | 1.00 |
blind write: read, apply, write |
1.00 | 0.00 | 0.00 | 0.00 |
| naive retry: conditional update, on 412 resend stale content with the new revision | 1.00 | 0.00 | 0.00 | 0.00 |
| noop: submit | 0.00 | 0.00 | 0.00 | 0.00 |
The oracle needs 3, 5, 9 and 5 tool calls on average on T0 to T3.
Open models through OpenRouter, 50 seeds per tier, default sampling (uv run --extra llm python baselines/run_llm_baseline.py --model <id> --yes). "used write" is the share of episodes where the model called the unconditional write at least once.
| model | T0 | T1 | T2 | T3 | used write |
|---|---|---|---|---|---|
| gpt-oss-120b | 1.00 | 1.00 | 1.00 | 0.78 | 0 to 2% |
| gpt-oss-20b | 0.86 | 0.46 | 0.34 | 0.44 | 0 to 2% |
Neither model reaches for the unconditional write; the 20b model mostly fails by not recovering after a 412 (re-reading and merging), and both lose points on T3, where the merge has to combine edits to the same field or list. Both runs together cost about $0.17.
uv sync
uv run server # serves on :8000
uv run python oracle/solve.py --url http://localhost:8000 --episodes 5
from lost_update_env import LostUpdateEnv
with LostUpdateEnv(base_url="http://localhost:8000").sync() as env:
meta = env.reset(seed=7, tier="T1").observation.metadata
print(meta["instruction"])
doc = env.call_tool("read", uri=meta["uri"])
print(env.call_tool("update", uri=meta["uri"], content=doc["content"], expected_revision=doc["revision"]))
From the Hub:
git clone https://huggingface.co/spaces/jayzuccarelli/lost_update_env
cd lost_update_env && uv sync && uv run server
GRPOTrainer(environment_factory=...) turns the public methods of a wrapper class into tools. examples/trl_grpo.py has the full script; the core is:
class SharedDocument:
def __init__(self):
self.client = LostUpdateEnv(base_url=ENV_URL).sync().connect()
def reset(self, **kwargs) -> str:
self.reward = 0.0
return self.client.reset().observation.metadata["instruction"]
def update(self, uri: str, content: str, expected_revision: str) -> str:
"""Replace the document only if its revision still equals expected_revision.
Args:
uri: Document URI.
content: The full new document as JSON text.
expected_revision: The revision your change is based on.
"""
return self._call("update", uri=uri, content=content, expected_revision=expected_revision)
# read, stat, write and submit follow the same pattern
def reward_fn(environments, **kwargs):
return [env.reward for env in environments]
trainer = GRPOTrainer(
model="Qwen/Qwen3-1.7B",
reward_funcs=reward_fn,
train_dataset=dataset,
environment_factory=SharedDocument,
args=GRPOConfig(num_generations=8),
)
Each rollout holds one session, so set MAX_CONCURRENT_ENVS on the server (default 8) to at least the number of parallel rollouts.
@inproceedings{yang2026concurrency,
title = {Position: Multi-Agent Systems Should Prioritize Concurrency Control},
author = {Yang, Xin and Li, Letian and Ji, Zimo and Zhang, Terry Jingchen and Jiang, Wenyuan},
booktitle = {International Conference on Machine Learning (ICML)},
year = {2026},
note = {arXiv:2608.18092}
}
BSD-3-Clause. Author: Eugenio "Jay" Zuccarelli.