Repository navigation
fix(server): dedupe concurrent conversation creates and forks - #5362
Conversation
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
|||||||||||||||||||||||||
fc22e9d to
5b158e5
Compare
5b158e5 to
58698a4
Compare
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>
58698a4 to
4cd079a
Compare
|
The remaining Validate PR description failure requires a short human-authored note between the protected |
all-hands-bot
left a comment
There was a problem hiding this comment.
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]andassert 2 == 1), confirming they actually open the check-then-act window. Acceptance criterion met. - I could not run the broader
tests/agent_serversuite here (it OOMs the box, as the PR notes), but CIagent-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:
-
Validate PR descriptionis 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 theHUMAN: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. -
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 concurrentoutput 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 — withoutsys.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 concurrentPOST /api/conversationswith the sameconversation_id) showing one201+ one200and a single message delivery would establish the residual race is genuinely closed in production. -
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_messagedeserialization — before it reaches the re-check, and on a genuinely preempting runtime two concurrent creates can both pass_start_event_service, yielding twoEventServices 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 theidempotent_conversation_create_v1capability 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
Non-blocking fork-path observationCorrectness 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 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
left a comment
There was a problem hiding this comment.
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_lifecycleand delegates to_reuse_existing_conversation_locked(), which correctly never re-acquires the non-reentrantasyncio.Lock._start_event_serviceregisters into_event_services/_conversation_recordsonly 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_conversationthe re-check raises before thetry, so the winner's directory is not removed by the loser'ssafe_rmtreecleanup -- 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 one201+ three idempotent200and one webhook, with forks collapsing to one201+ three409and 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 checkis clean on the changed files. Current-head CI is green foragent-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>
|
Follow-up pushed in Fork path: this was a real bug, not just wasted work. The losing fork's Residual create window (earlier review, finding 3): I'm not changing this. The existence re-check and Still blocking: This comment was created by an AI agent (OpenHands) on behalf of Graham Neubig. |
all-hands-bot
left a comment
There was a problem hiding this comment.
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_lifecycleand delegates to_reuse_existing_conversation_locked(), which never re-acquires the non-reentrantasyncio.Lock._start_event_serviceregisters into_event_services/_conversation_recordsonly 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'sbase_state.jsonbefore failing. Resolvingfork_conv_idup front and holding the lock acrossfork(), the metadata copy, and_start_event_servicemakes the loser raiseConversation with id <id> already existsbefore any write, so the winner's directory and the router's409mapping are both correct. - The updated
test_concurrent_fork_same_id_forks_onceseeds the source with an event and delays the secondfork()until the winner is registered, which is exactly the harmful interleave; it asserts the exactConversation with id ... already existsmessage rather than the coincidental"already exists"substring. - Live fork-lock validation in the PR body (real
openhands.agent_serverprocess, concurrent HTTP, on-diskbase_state.jsoninspection) shows the winner's file rewritten in 20/20 trials on the old head and 0/20 on this head, with1x201 + 7x200creates and exactly one userMessageEvent. 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 checkclean on the changed files. Current-head CI is green foragent-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
Co-authored-by: openhands <openhands@all-hands.dev>
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_conversationchecked whether a conversation id already existed before taking that conversation's lifecycle lock, then created inside it: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:
EventServices for one conversation id,is_new=Truefor both callers, andinitial_messagedelivered twice.The server already advertises an idempotent contract —
conversation_router.pyreturns201 if is_new else 200and 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_conversationhad the same shape: the duplicate-id check precedes the fork's persistence work.Summary
Re-check existence inside
_conversation_lifecycleand reuse the winner instead of creating again. Both the fast path and the create-path re-check now share one_reuse_existing_conversation_lockedhelper, which is only ever called with the lock already held (it must not re-acquire it:asyncio.Lockis not reentrant).For
fork_conversationthe re-check raises before thetry, 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_v1is 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_oncetest_concurrent_fork_same_id_forks_onceThe pre-existing
test_start_conversation_with_duplicate_idis sequential — itawaits 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:tests/agent_server/test_conversation_service.py,test_conversation_router.pyandtest_server_details_router.py: 222 passed.ruff checkandruff formatclean.I did not run the whole
tests/agent_serversuite 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:Both new tests fail on the previous code and pass after:
Issue Number
Fixes #5035.
Type
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
af3ed3ed99715fabd385058bec31d538336c9740against head4cd079abe4c3df906d2ae968729b6e8b1e268a4bwith real agent-server processes, real concurrent HTTP requests, persistence, and a real localhost webhook sink.201; four webhooks201, three idempotent200; one webhook201201, three409201for each201for eachThe 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'sbase_state.json, and then failed on the winner's already-persisted events. Its 409 carried an internalEvent with ID '…' already exists at index 0detail that matched the router's"already exists"check only by coincidence.a4cdbd9resolves the fork id up front and holds its lifecycle lock acrossfork()and the event-service start.Live run: a real
python -m openhands.agent_serverprocess with a scrubbed env (env -i PATH HOME) and a temporaryconversations_path. Real concurrent HTTP viahttpx. Each fork trial fires 8 concurrentPOST /api/conversations/{src}/fork {"id": <same id>}at a source conversation that has events. "Rewrote" means the winner's on-diskbase_state.jsonagent.llm.api_keychanged from the winner'snullto the redacted sentinel written by a loser.4cd079aa4cdbd9initial_message201, 7×200; 1 userMessageEvent201, 7×200; 1 userMessageEvent201, 140×409201, 140×409Event with ID '<id>' already exists at index 0(×140)Conversation with id <id> already exists(×140)base_state.jsonidempotent_conversation_create_v1in/server_infoRegression test:
test_concurrent_fork_same_id_forks_oncenow seeds the source with an event and delays the secondfork()until the winner is registered. It fails on4cd079aand passes ona4cdbd9:Test runs on
a4cdbd9:pytest -n 8 tests/agent_server: 2221 passed. The 12 remaining items weretest_agent_server_wsproto.py(server start) and onetest_terminal_service.pyenv-dump check. They are environmental: the sandbox's ownOH_*/SESSION_API_KEYenv leaks into these tests. Rerun withenv -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
eclipse-temurin:17-jdkpython-node-runtimepython-node-runtimepython-node-runtimegolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:a449857-pythonRun
All tags pushed for this build
About Multi-Architecture Support
a449857-python) is a multi-arch manifest supporting both amd64 and arm64a449857-python-amd64) are also available if needed