Skip to content

fix(server): dedupe concurrent conversation creates and forks - #5362

Merged
neubig merged 3 commits into
mainfrom
fix/idempotent-conversation-create
Oct 3, 2026
Merged

neubig merged 3 commits into
mainfrom
fix/idempotent-conversation-create

Conversation

@neubig

@neubig neubig commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

Human review note: the fork lifecycle lock and concurrent-create deduplication changes are intended to preserve existing conversation semantics while closing the reported race.

AGENT:

Why

ConversationService._start_conversation checked whether a conversation id already existed before taking that conversation's lifecycle lock, then created inside it:

conversation_id = request.conversation_id or uuid4()
existing_record = self._conversation_records.get(conversation_id)   # check
existing_event_service = self._event_services.get(conversation_id)
if existing_record is not None or (...):
    async with self._conversation_lifecycle(conversation_id):       # act
        ...

The prepare step between the check and the lock awaits, so two concurrent creates for one id can both pass the check, then serialize on the lock and both run the new-conversation path:

  • two EventServices for one conversation id,
  • is_new=True for both callers, and
  • the conversation's initial_message delivered twice.

The server already advertises an idempotent contract — conversation_router.py returns 201 if is_new else 200 and the reuse path skips the initial message — so this is a bug in an existing contract, not new machinery. Only the check-then-act window was wrong.

fork_conversation had the same shape: the duplicate-id check precedes the fork's persistence work.

Summary

Re-check existence inside _conversation_lifecycle and reuse the winner instead of creating again. Both the fast path and the create-path re-check now share one _reuse_existing_conversation_locked helper, which is only ever called with the lock already held (it must not re-acquire it: asyncio.Lock is not reentrant).

For fork_conversation the re-check raises before the try, deliberately: the cleanup handler removes the fork directory, which by this point holds the winner's data, so letting the exception pass through it would corrupt the surviving conversation.

idempotent_conversation_create_v1 is added to the advertised capabilities so a client can gate a re-send of a create whose response it never saw on a server that actually provides idempotency.

Notes

A client that loses the response to a create (timeout, dropped connection) currently has no safe way to find out whether the conversation exists: re-sending the POST could double-create and re-run the initial message. That is the situation motivating #5035, where the workaround is a bounded GET /api/conversations/{id} polling loop that never replays the POST. With this fix, a plain re-send is safe, and such a client can retry instead of polling.

How to Test

Two regression tests, in the places the existing tests missed:

  • test_concurrent_start_conversation_same_id_starts_event_service_once
  • test_concurrent_fork_same_id_forks_once

The pre-existing test_start_conversation_with_duplicate_id is sequential — it awaits the first call before issuing the second — so it never opened the window. Both new tests fail on the previous code and were confirmed failing before the fix was applied:

assert sorted(is_new for _, is_new in results) == [False, True]
E   assert [True, True] == [False, True]

assert len(successes) == 1
E   AssertionError: assert 2 == 1

tests/agent_server/test_conversation_service.py, test_conversation_router.py and test_server_details_router.py: 222 passed. ruff check and ruff format clean.

I did not run the whole tests/agent_server suite in this environment of my own accord — it OOMs the box — but the touched surfaces are covered above.


This PR was created by an AI agent (OpenHands) on behalf of @neubig.

End-to-end reproduction (not just unit tests)

The race was reproduced against the server directly, before the fix, by forcing the interleave with a await asyncio.sleep(0) between the existence check and the lock:

pre-fix:  create A -> is_new=True, create B -> is_new=True   # both created
          _start_event_service ran TWICE for one conversation id
post-fix: create A -> is_new=True, create B -> is_new=False  # B reused A
          _start_event_service ran ONCE

Both new tests fail on the previous code and pass after:

$ uv run pytest tests/agent_server/test_conversation_service.py -k concurrent
assert sorted(is_new for _, is_new in results) == [False, True]
E   assert [True, True] == [False, True]        # pre-fix

assert len(successes) == 1
E   AssertionError: assert 2 == 1               # pre-fix fork
$ uv run pytest tests/agent_server/test_conversation_service.py \
      tests/agent_server/test_conversation_router.py \
      tests/agent_server/test_server_details_router.py -q
222 passed, 2 warnings in 29.75s

Issue Number

Fixes #5035.

Type

  • Bug fix

Notes

This is the server-side half; #5363 is the client half. It is safe to land
independently, and an old client simply keeps its current behavior.

Live main-vs-PR validation

Validated exact base af3ed3ed99715fabd385058bec31d538336c9740 against head 4cd079abe4c3df906d2ae968729b6e8b1e268a4b with real agent-server processes, real concurrent HTTP requests, persistence, and a real localhost webhook sink.

Probe Base Head
4 same-ID creates four 201; four webhooks one 201, three idempotent 200; one webhook
4 same-ID forks four 201 one 201, three 409
distinct-ID controls 201 for each 201 for each

The base ran four create initializations and four concurrent fork writers for one logical resource; head admitted one winner without suppressing distinct requests. The relevant implementation is unchanged between the captured base and current main.

Verdict: confirmed. Cross-instance lease contention and LLM turns were not part of this single-server identity test.

This validation section was added by an AI agent (OpenHands) on behalf of Graham Neubig.

Follow-up: fork persistence write moved under the lock (a4cdbd9)

The earlier fork re-check ran after LocalConversation.fork(), which executes outside the lifecycle lock. A loser that passed the pre-lock duplicate check therefore reopened the winner's persistence directory, rewrote the live winner's base_state.json, and then failed on the winner's already-persisted events. Its 409 carried an internal Event with ID '…' already exists at index 0 detail that matched the router's "already exists" check only by coincidence. a4cdbd9 resolves the fork id up front and holds its lifecycle lock across fork() and the event-service start.

Live run: a real python -m openhands.agent_server process with a scrubbed env (env -i PATH HOME) and a temporary conversations_path. Real concurrent HTTP via httpx. Each fork trial fires 8 concurrent POST /api/conversations/{src}/fork {"id": <same id>} at a source conversation that has events. "Rewrote" means the winner's on-disk base_state.json agent.llm.api_key changed from the winner's null to the redacted sentinel written by a loser.

Probe old head 4cd079a new head a4cdbd9
8 same-id creates with initial_message 1×201, 7×200; 1 user MessageEvent 1×201, 7×200; 1 user MessageEvent
8 same-id forks × 20 trials 20×201, 140×409 20×201, 140×409
409 detail Event with ID '<id>' already exists at index 0 (×140) Conversation with id <id> already exists (×140)
trials where a loser rewrote the winner's base_state.json 20 / 20 0 / 20
idempotent_conversation_create_v1 in /server_info yes yes

Regression test: test_concurrent_fork_same_id_forks_once now seeds the source with an event and delays the second fork() until the winner is registered. It fails on 4cd079a and passes on a4cdbd9:

E   At index 0 diff: "Event with ID 'e7ef310f-…' already exists at index 0" != 'Conversation with id 5fefae82-… already exists'

Test runs on a4cdbd9:

  • pytest -n 8 tests/agent_server: 2221 passed. The 12 remaining items were test_agent_server_wsproto.py (server start) and one test_terminal_service.py env-dump check. They are environmental: the sandbox's own OH_*/SESSION_API_KEY env leaks into these tests. Rerun with env -i: 28/28 passed.
  • pytest -n 8 tests/cross: 491 passed, 1 skipped.
  • pre-commit (ruff, pycodestyle, pyright, import rules) passes on both changed files.

This section was added by an AI agent (OpenHands) on behalf of Graham Neubig.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:a449857-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-a449857-python \
  ghcr.io/openhands/agent-server:a449857-python

All tags pushed for this build

ghcr.io/openhands/agent-server:a449857-golang-amd64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-golang-amd64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-golang-amd64
ghcr.io/openhands/agent-server:a449857-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:a449857-golang-arm64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-golang-arm64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-golang-arm64
ghcr.io/openhands/agent-server:a449857-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:a449857-java-amd64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-java-amd64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-java-amd64
ghcr.io/openhands/agent-server:a449857-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:a449857-java-arm64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-java-arm64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-java-arm64
ghcr.io/openhands/agent-server:a449857-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:a449857-python-amd64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-amd64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-amd64
ghcr.io/openhands/agent-server:a449857-python-node-runtime-amd64
ghcr.io/openhands/agent-server:a449857-python-arm64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-arm64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-arm64
ghcr.io/openhands/agent-server:a449857-python-node-runtime-arm64
ghcr.io/openhands/agent-server:a449857-python-minimal-amd64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-minimal-amd64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-minimal-amd64
ghcr.io/openhands/agent-server:a449857-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:a449857-python-minimal-arm64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-minimal-arm64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-minimal-arm64
ghcr.io/openhands/agent-server:a449857-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:a449857-python-slim-amd64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-slim-amd64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-slim-amd64
ghcr.io/openhands/agent-server:a449857-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:a449857-python-slim-arm64
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-slim-arm64
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-slim-arm64
ghcr.io/openhands/agent-server:a449857-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:a449857-golang
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-golang
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-golang
ghcr.io/openhands/agent-server:a449857-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:a449857-java
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-java
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-java
ghcr.io/openhands/agent-server:a449857-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:a449857-python-minimal
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-minimal
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-minimal
ghcr.io/openhands/agent-server:a449857-python-node-runtime-minimal
ghcr.io/openhands/agent-server:a449857-python-slim
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python-slim
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python-slim
ghcr.io/openhands/agent-server:a449857-python-node-runtime-slim
ghcr.io/openhands/agent-server:a449857-python
ghcr.io/openhands/agent-server:a4498572b53074cc5e1bd162a541dfebdb3e302d-python
ghcr.io/openhands/agent-server:fix-idempotent-conversation-create-python
ghcr.io/openhands/agent-server:a449857-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., a449857-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., a449857-python-amd64) are also available if needed

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   conversation_service.py120712690%168–169, 178, 231–232, 236–237, 242, 407–408, 469, 574, 581–582, 683, 769, 820–821, 828, 860–861, 877, 908, 912, 924, 944, 956–959, 965–966, 975, 977, 1043, 1053, 1078, 1084–1085, 1089–1090, 1098, 1125, 1131, 1225, 1231, 1236, 1242, 1250–1251, 1260–1263, 1272, 1285, 1303, 1340, 1344–1348, 1371, 1420, 1433, 1441, 1499–1500, 1504, 1532–1533, 1681–1683, 1685–1686, 1689–1690, 1727, 1801–1802, 1834, 1837, 1844–1846, 1849–1850, 1854–1856, 1859–1860, 1864–1866, 1869–1870, 1899, 1908, 1961, 1969–1971, 2032, 2035, 2062, 2072, 2077–2080, 2094, 2105, 2117–2118, 2150, 2247, 2306, 2364, 2379–2380, 2763, 2816, 2819
   server_details_router.py66297%27–28
TOTAL471751901860% 

The existence check in _start_conversation ran before the conversation's
lifecycle lock, but the create that follows it runs inside. Two concurrent
creates for one conversation id could therefore both pass the check and both
run the new-conversation path: two EventServices for one id, is_new true for
both callers, and the initial message delivered twice.

Re-check under the lifecycle lock and reuse the winner instead of creating a
second time. Apply the same re-check to fork_conversation, where the duplicate
id check likewise precedes the fork's persistence work; the re-check raises
before the cleanup handler, which removes the fork directory and would
otherwise delete the winner's data.

Advertise idempotent_conversation_create_v1 so clients can gate a re-send of a
create whose response they never saw on the server actually providing it.

The existing duplicate-id test awaited its first call before the second, so it
never exercised the window. Add concurrent create and fork tests; both fail on
the previous code.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig force-pushed the fix/idempotent-conversation-create branch from 58698a4 to 4cd079a Compare September 29, 2026 03:18
@neubig
neubig marked this pull request as ready for review September 29, 2026 09:44
@neubig

neubig commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

The remaining Validate PR description failure requires a short human-authored note between the protected HUMAN: and AGENT: markers. I did not fabricate that attestation. Please add a brief note in your own words; CI should then rerun automatically.\n\n_This comment was created by an AI agent (OpenHands) on behalf of Gregory Neubig._

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

The core concurrency fix is correct and well-targeted. _has_conversation() is a cheap pre-lock hint, and the create path now re-checks existence inside _conversation_lifecycle and delegates to _reuse_existing_conversation_locked(), which never re-acquires the (non-reentrant) asyncio.Lock. For fork_conversation the re-check raises before the try, so the winner's directory is not removed by the loser's safe_rmtree cleanup — the deliberate ordering the PR calls out is guarded correctly.

Verification I performed on the current head (workspace, detached at 4cd079ab):

  • uv run pytest tests/agent_server/test_conversation_service.py -k "concurrent or duplicate" → 5 passed.
  • The two new tests fail on the base revision (assert [True, True] == [False, True] and assert 2 == 1), confirming they actually open the check-then-act window. Acceptance criterion met.
  • I could not run the broader tests/agent_server suite here (it OOMs the box, as the PR notes), but CI agent-server-tests, agent-server-api, cross-tests, integration-test, and the OpenAPI breakage checks are green on this head.

Material findings that require attention before an unconditional approval:

  1. Validate PR description is failing on this head (job 109349414226): Add a short human-written note between HUMAN: and AGENT:. Per repository policy an AI agent must not fill the HUMAN: field; it has only a template placeholder and no visible human note. This is a merge gate the human author needs to satisfy. Please do not edit that section — ask the author to add their own note.

  2. No authentic end-to-end runtime evidence. The PR description's "How to Test" shows a unit-level reproduction; the referenced uv run pytest ... -k concurrent output only demonstrates the unit tests. The repository's review guide requires, for production-facing bug fixes, an authentic before/after run through the real service-facing workflow. This matters more than usual here because the guarantee is host-dependent — without sys.setswitchinterval(0) or free-threaded Python, CPython's default 5 ms switch interval lets a single request's pre-lock preparation complete without yielding, and the tested paths pass even with the re-check removed. A live interleave (two concurrent POST /api/conversations with the same conversation_id) showing one 201 + one 200 and a single message delivery would establish the residual race is genuinely closed in production.

  3. Residual gap this fix does not close (root cause of the motivating #5035 symptom). A concurrent loser can still begin full request preparation — profile resolution, worktree creation, initial_message deserialization — before it reaches the re-check, and on a genuinely preempting runtime two concurrent creates can both pass _start_event_service, yielding two EventServices for one id and a duplicate initial-message delivery. The re-check narrows the window substantially; it is not an atomic consumption of the request. Since the idempotent_conversation_create_v1 capability is what the client will gate a create re-send on, the residual window under real parallelism deserves a call-out.

Non-blocking observation: the loser's eager Conversation.fork() (and worktree setup in _prepare_request_workspace) runs before fork_conversation takes the lifecycle lock and therefore before the new re-check. Once an id exists on disk, that call can reopen the winner's directory. I could not demonstrate persisted corruption from this (the copy round-trip appears to be a no-op write once data already exists), but moving the fork persistence write inside the locked region would remove the shared-directory race altogether.

Given finding 1 is a repository-owned merge gate and findings 2–3 bear on whether the linked issue's acceptance criteria are fully demonstrated at runtime, I am deferring rather than approving.

🔄 CHANGES REQUESTED

@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot September 29, 2026 17:09
@neubig

neubig commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

Non-blocking fork-path observation

Correctness passed the concurrent A/B test, but four same-ID fork requests on head still produced four “Forked conversation” log entries before one returned 201 and three returned 409. The expensive LocalConversation.fork()/copy work happens before the under-lock _has_conversation re-check, so losing requests do redundant copy work even though they no longer persist duplicate resources or delete the winner.

This is a performance/cleanup follow-up, not a correctness blocker; the PR successfully enforces one persisted winner.

This comment was created by an AI agent (OpenHands) on behalf of Graham Neubig.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

I re-checked the current head 4cd079ab and the repository guidance (AGENTS.md, .agents/skills/custom-codereview-guide.md). The change is in-scope for this repository (Agent Server conversation lifecycle, no public API/schema change beyond an additive capability string), so I proceeded to the technical review.

What I verified on the current head

  • _has_conversation() is only a cheap pre-lock hint; the create path re-checks existence inside _conversation_lifecycle and delegates to _reuse_existing_conversation_locked(), which correctly never re-acquires the non-reentrant asyncio.Lock. _start_event_service registers into _event_services/_conversation_records only after a successful start, so the loser's re-check observes the winner and reuses it (is_new=False, no second EventService, initial message sent once).
  • For fork_conversation the re-check raises before the try, so the winner's directory is not removed by the loser's safe_rmtree cleanup -- the ordering the PR calls out is correctly guarded.
  • The live main-vs-PR validation added to the PR body (real agent-server processes, concurrent HTTP, persistence, webhook sink) is the authentic before/after service-level evidence the review guide requires for a production-facing lifecycle fix: base produced four 201/four webhooks for one id, head produced one 201 + three idempotent 200 and one webhook, with forks collapsing to one 201 + three 409 and distinct-id controls unaffected. This closes the previous review's request for runtime evidence and confirms the residual pre-lock preparation window does not yield a duplicate persisted resource on this single-server test.
  • uv run pytest tests/agent_server/test_conversation_service.py tests/agent_server/test_conversation_router.py tests/agent_server/test_server_details_router.py -q -> 222 passed on this head. ruff check is clean on the changed files. Current-head CI is green for agent-server-tests, agent-server-api, cross-tests, integration-test, sdk-tests, and the OpenAPI breakage checks.

One merge gate remains, and it is not a code defect

The Validate PR description check is still failing on this head (Add a short human-written note between HUMAN: and AGENT:). .github/scripts/check_pr_description.py requires a human-authored note of at least the minimum length in the HUMAN: section, and repository policy forbids an AI agent from writing it. This is a human-only attestation the author must add; merge stays blocked until then. I am not treating it as a code finding, and I am not editing that section.

The fork-path performance observation raised in the latest author comment (losers do redundant LocalConversation.fork() copy work before the under-lock re-check) is acknowledged by the author as a non-blocking follow-up and does not affect correctness of the persisted-winner guarantee.

No material correctness, security, compatibility, or acceptance-criterion defect remains in the changed code.

✅ APPROVED

fork_conversation re-checked the fork id under the lifecycle lock, but only
after LocalConversation.fork() had already run outside it. A loser that passed
the pre-lock duplicate check therefore reopened the winner's persistence
directory, rewrote the live winner's base_state.json, and then failed on the
winner's already-persisted events, surfacing an internal "Event with ID ...
already exists at index 0" error that only mapped to 409 by coincidence.

Resolve the fork id up front and hold its lifecycle lock across fork() and the
event-service start, so losers are rejected before touching the directory.

The concurrent fork regression test now seeds the source with an event and
forces the loser's fork() to run after the winner commits; it fails on the
previous code.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig

neubig commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

Follow-up pushed in a4cdbd9 (fast-forward from 4cd079a). It fixes the fork-path observation from the earlier all-hands-bot review and @neubig's non-blocking comment.

Fork path: this was a real bug, not just wasted work. The losing fork's LocalConversation.fork() ran before the lifecycle lock, so it reopened the winner's persistence directory, rewrote the live winner's base_state.json, and then failed on the winner's persisted events. Its 409 detail was the internal Event with ID '…' already exists at index 0. I ran a real agent-server with 20 trials of 8 concurrent same-id forks: on 4cd079a a loser rewrote the winner's base_state.json in 20/20 trials; on a4cdbd9 it happened in 0/20, and every loser got the clean Conversation with id … already exists 409. The fix picks the fork id up front and holds its lifecycle lock across fork() and the event-service start. test_concurrent_fork_same_id_forks_once now forces that interleave and fails on the old code. Full table and commands are in the PR body's AGENT section.

Residual create window (earlier review, finding 3): I'm not changing this. The existence re-check and _start_event_service's registration into _conversation_records/_event_services both happen under the same per-id asyncio.Lock. Two creates for one id therefore can't both reach the new-conversation path on a single server, whatever the thread switch interval. The live run shows 1×201 + 7×200 and exactly one user MessageEvent. Pre-lock preparation by a loser (profile resolution, etc.) is wasted work that gets discarded. It doesn't produce a second EventService or a second delivery.

Still blocking: Validate PR description needs a human-written note under HUMAN:. I did not edit that section.

This comment was created by an AI agent (OpenHands) on behalf of Graham Neubig.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Fresh pass on the new head a4cdbd9bc4aec6860ae569071de7a72e2c341e73 (the earlier approval was on 4cd079ab, so this is a new revision, not a duplicate). The change is in-scope for this repository (Agent Server conversation lifecycle; the only public-surface addition is the additive idempotent_conversation_create_v1 capability string), so I proceeded with the technical review.

What I verified on this head

  • The create path re-checks existence inside _conversation_lifecycle and delegates to _reuse_existing_conversation_locked(), which never re-acquires the non-reentrant asyncio.Lock. _start_event_service registers into _event_services/_conversation_records only after a successful start, so a concurrent loser observes the winner and reuses it (is_new=False, one EventService, initial message sent once).
  • The fork fix is the substantive change in this revision, and it resolves a real defect: LocalConversation.fork() now runs inside the fork id's lifecycle lock, after the under-lock duplicate re-check. Previously the losing fork reopened the winner's persistence directory and rewrote the live winner's base_state.json before failing. Resolving fork_conv_id up front and holding the lock across fork(), the metadata copy, and _start_event_service makes the loser raise Conversation with id <id> already exists before any write, so the winner's directory and the router's 409 mapping are both correct.
  • The updated test_concurrent_fork_same_id_forks_once seeds the source with an event and delays the second fork() until the winner is registered, which is exactly the harmful interleave; it asserts the exact Conversation with id ... already exists message rather than the coincidental "already exists" substring.
  • Live fork-lock validation in the PR body (real openhands.agent_server process, concurrent HTTP, on-disk base_state.json inspection) shows the winner's file rewritten in 20/20 trials on the old head and 0/20 on this head, with 1x201 + 7x200 creates and exactly one user MessageEvent.
  • uv run pytest tests/agent_server/test_conversation_service.py tests/agent_server/test_conversation_router.py tests/agent_server/test_server_details_router.py -q -> 222 passed; -k "concurrent or duplicate" -> 5 passed; ruff check clean on the changed files. Current-head CI is green for agent-server-tests, agent-server-api, cross-tests, integration-test, sdk-tests, and the OpenAPI checks.

One merge gate remains, and it is not a code defect

Validate PR description still fails on this head (Add a short human-written note between HUMAN: and AGENT:). .github/scripts/check_pr_description.py requires a human-authored note in the HUMAN: section, and repository policy forbids an AI agent from writing it. The author must add it; merge stays blocked until then. I am not treating it as a code finding and am not editing that section.

No material correctness, security, compatibility, or acceptance-criterion defect remains in the changed code.

✅ APPROVED

@neubig
neubig enabled auto-merge (squash) October 3, 2026 19:14
Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig merged commit 8ac973c into main Oct 3, 2026
45 checks passed
@neubig
neubig deleted the fix/idempotent-conversation-create branch October 3, 2026 19:26
This was referenced Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recover a conversation after the TypeScript client loses its create response

3 participants