Skip to content

fix(client): recover lost conversation create responses - #5036

Closed
neubig wants to merge 5 commits into
mainfrom
fix/typescript-create-response-recovery
Closed

neubig wants to merge 5 commits into
mainfrom
fix/typescript-create-response-recovery

Conversation

@neubig

@neubig neubig commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

HUMAN:


AGENT:

Why

A conversation can be created and its initial message started even if its POST response is lost. The TypeScript client currently reports failure, leaving Canvas unable to open that conversation.

Summary

Reconcile a transport failure with bounded GET requests using the caller's stable conversation ID. Never replay the create POST. Preserve ordinary HTTP errors and the original transport error if reconciliation fails. This is independent of runtime routing and Docker provisioning.

Issue Number

Closes #5035. Extracted from #4966 to keep the runtime API foundation focused.

How to Test

cd clients/typescript && npm run test:coverage && npm run lint && npm run build && npm run format:check

324 tests passed across 20 suites. Six targeted tests use a real HTTP server to drop a successful POST response, delay visibility, and check rejection handling. Against main, four recovery scenarios fail; all six pass with this fix. Live before/after Canvas recordings inject the same lost successful response. Before the fix, Canvas shows disconnected errors; after it, the real agent completes without an error. Both cases send exactly one create POST.

Type

  • Bug fix

Notes

Targets main and is not a prerequisite for the runtime PR stack. The default additional recovery window is 120 seconds; callers can set creationRecoveryTimeout.


🐳 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:566dbaf-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:566dbaf-golang-amd64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-golang-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-golang-amd64
ghcr.io/openhands/agent-server:566dbaf-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:566dbaf-golang-arm64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-golang-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-golang-arm64
ghcr.io/openhands/agent-server:566dbaf-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:566dbaf-java-amd64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-java-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-java-amd64
ghcr.io/openhands/agent-server:566dbaf-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:566dbaf-java-arm64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-java-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-java-arm64
ghcr.io/openhands/agent-server:566dbaf-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:566dbaf-python-amd64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-amd64
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-amd64
ghcr.io/openhands/agent-server:566dbaf-python-arm64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-arm64
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-arm64
ghcr.io/openhands/agent-server:566dbaf-python-minimal-amd64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-minimal-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-minimal-amd64
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:566dbaf-python-minimal-arm64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-minimal-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-minimal-arm64
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:566dbaf-python-slim-amd64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-slim-amd64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-slim-amd64
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:566dbaf-python-slim-arm64
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-slim-arm64
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-slim-arm64
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:566dbaf-golang
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-golang
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-golang
ghcr.io/openhands/agent-server:566dbaf-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:566dbaf-java
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-java
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-java
ghcr.io/openhands/agent-server:566dbaf-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:566dbaf-python-minimal
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-minimal
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-minimal
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-minimal
ghcr.io/openhands/agent-server:566dbaf-python-slim
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python-slim
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python-slim
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime-slim
ghcr.io/openhands/agent-server:566dbaf-python
ghcr.io/openhands/agent-server:566dbaf084bda7363ae8c6c9130b20cda6575234-python
ghcr.io/openhands/agent-server:fix-typescript-create-response-recovery-python
ghcr.io/openhands/agent-server:566dbaf-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., 566dbaf-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., 566dbaf-python-amd64) are also available if needed

Closes #5035

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

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

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

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor
  ✅ **PR Artifacts Cleaned Up**

  The `.pr/` directory is no longer present.

@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).

Reviewed head 7b69c3a776d8897a6fbe39896e88701ca55b9f6f (fetched and checked out detached). Scope: clients/typescript/ is owned by this repository (it mirrors the Agent Server API), so the change belongs here.

Verdict: no material findings. The fix is targeted and well tested.

What I verified against the workspace:

  • Traced the error boundary in HttpClient.request (src/client/http-client.ts): lost sockets and aborts surface as plain Error (Request failed: ... / Request timeout after ...), while non-2xx responses raise HttpError. The recovery predicate !(error instanceof HttpError) matches exactly that boundary, so explicit HTTP failures (400/401/422/...) still propagate and the test does not conceal server validation failures is correct.
  • Confirmed the create POST is never replayed: recovery uses bounded GET /api/conversations/{id} keyed on the caller's stable payload.conversation_id (the same field ConversationConfig.conversation_id accepts server-side), with a per-request timeout clamped to the remaining deadline and a 404-only continue condition. Any non-404 HttpError (e.g. 401) breaks immediately and the original transport error is rethrown, matching the acceptance criteria.
  • Ran the focused suite (conversation-create-recovery.test.ts): 6/6 pass; full suite: 20 suites / 324 tests pass; npm run build and npm run lint pass (only pre-existing warnings elsewhere).
  • All GitHub Actions check-runs for this head are success or intentionally skipped (build, coverage-report, agent-server-tests, integration-test, windows-tests, REST API/OpenAPI, public-type-budget, etc.).

Non-blocking observation (not a blocker): the recovery window is only configurable by constructing ConversationClient directly — creationRecoveryTimeout is not plumbed through OpenHandsClientOptions/AgentServerClient, so agentServer.conversations callers can only use the 120s default. That default also means a caller who supplied conversation_id waits the full window before the original transport error surfaces when the server is genuinely unreachable. The trade-off is documented in the PR notes and is intentional, so this is a follow-up consideration, not a merge blocker.

✅ APPROVED

@neubig
neubig marked this pull request as ready for review September 28, 2026 15:02
The recovery tests asserted the client rethrows `Unknown request error`, which
only held under Jest: there, the failed `fetch` surfaced as a non-Error. After
the client moved to Vitest (#5075), Node's `fetch` throws a real `TypeError`
(`fetch failed`), so `HttpClient` takes its `Request failed: <message>` branch
and all three assertions failed.

Assert the stable contract instead -- a generic (non-`HttpError`) `Error` that
preserves the cause. All 336 client tests pass.

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

@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).

Re-reviewed head 566dbaf084bda7363ae8c6c9130b20cda6575234 (fetched and checked out detached). Treating this as a fresh review of the current head; the PR is now out of draft.

Scope: clients/typescript/ is owned by this repository (it mirrors the Agent Server API), so the change belongs here. The production change is the same recovery logic as before; the test file was adjusted to stop asserting a runner-specific transport-error string, and the .pr/ artifacts were removed after the main merge.

Implementation assessment: no code defect found.

  • Traced HttpClient.request: lost sockets/aborts surface as plain Error (Request failed: ... / Request timeout after ..., carrying cause), while non-2xx responses raise HttpError. So !(error instanceof HttpError) targets exactly transport failures, and a real 400/401/422 still propagates (does not conceal server validation failures is correct).
  • The create POST is never replayed: reconciliation GETs /api/conversations/{id} keyed on the caller's payload.conversation_id, with a per-request timeout clamped to the remaining deadline, continues only on 404, and rethrows the original transport error otherwise (including on a 401 stop).
  • Ran the focused suite: 6/6 pass. Full suite at this head: 21 files / 336 tests pass. npm run build, npm run lint, npm run format:check all clean. Every other check-run for this head is success or intentionally skipped.

Material finding — the required Validate PR description check fails on this head (run 36459801107, job 109055224690), with two independent causes:

  1. Empty HUMAN: section. .github/scripts/check_pr_description.py requires a human-written note of at least 20 visible characters between HUMAN: and AGENT:. AGENTS.md makes this section human-only and forbids AI agents from editing it, so the author must add it in their own words.
  2. Linked-issue readiness. The validator also reports: "Linked issue(s) (#5035, #4966) carry neither ready-for-dev nor a pre-rollout creation date." #5035 was created 2026-09-14, after the 2026-08-13 rollout cutoff, and currently carries only enhancement, sdk, javascript (its ready-for-dev label was removed on 2026-09-14). Because #5035 is linked both through Closes #5035 and the ## Issue Number section, this repository gate blocks merge until a maintainer confirms the issue is implementation-ready or re-applies ready-for-dev. (#4966, created 2026-09-12, is grandfathered.)

Also note, for the maintainer rather than as a code blocker: the PR body's "Live before/after Canvas recordings" link targets .pr/sdk5036-create-response-recovery/README.md at 7b69c3a776, which is no longer the head, and .pr/ no longer exists on this head (the old commit still resolves, HTTP 200). The current review guide's live-evidence checkpoint asks for before/after evidence tied to the current revision; the production file is byte-identical to what was recorded at e27c6d24, so the recordings likely still apply, but confirming that is a maintainer call.

Deferring the readiness label and the human-authored note to a maintainer.

🔄 CHANGES REQUESTED

@neubig

neubig commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Closing this in favor of #5362 (server) and #5363 (client).

The diagnosis here is correct and the test harness is a good one — driving a real HTTP server to drop a successful POST response is the right way to exercise this, and I kept that approach in the replacement. The difference is the remedy.

This PR reconciles a lost response with bounded GET /api/conversations/{id} reads and, per its acceptance criteria, never replays the POST. That constraint is the part I think is wrong. Replaying is only unsafe because of a server bug: ConversationService._start_conversation ran the existing-conversation check before the conversation's lifecycle lock and the create inside it, and the work in between awaits. Two concurrent creates for one id could both pass the check, then serialize on the lock and both run the new-conversation path — two EventServices for one id, is_new=True for both callers, and the initial_message delivered twice. fork_conversation had the same window. I reproduced this directly: both concurrent same-id creates returned is_new=True, and _start_event_service ran twice for one conversation id.

Notably, the server already advertised exactly the contract this PR works around — conversation_router.py returns 201 if is_new else 200, and the reuse path skips the initial message. So the idempotency was specified but not enforced against concurrency.

#5362 makes the re-check happen inside the lifecycle lock, so a re-sent create is deduplicated: one applies, the other gets the existing conversation back, and the initial message runs once. #5363 then lets the client simply re-send — once, only where the server advertises idempotent_conversation_create_v1, and only when the caller supplied a conversation_id.

Why I prefer that: it recovers the value from the authority that owns it instead of reconstructing it by polling, it needs no new creationRecoveryTimeout option, and the reconciliation loop's failure modes (how long to wait, which errors to stop on) disappear rather than needing to be tuned. The retry also makes the operation idempotent for any caller, not just callers that happen to poll.

The work here is not wasted — the concurrency tests in #5362 cover the window this PR was certifying around, and #5035's acceptance criteria have been updated since they previously mandated never replaying the POST.


This comment was written by an AI agent (OpenHands) on behalf of @neubig.

@neubig neubig closed this Sep 29, 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