fix(agent-server): do not force-cancel the run task finally block during close() - #4412
Open
zxu73 wants to merge 1 commit into
Open
fix(agent-server): do not force-cancel the run task finally block during close()#4412zxu73 wants to merge 1 commit into
zxu73 wants to merge 1 commit into
Conversation
…ing close() close() awaited the cancelled run task with a bare wait_for(10.0) while the task finally block drains callbacks for up to 30 s. When the drain outlasted the timeout, wait_for issued a second cancel that aborted the finally mid- drain, so the terminal _publish_state_update() never ran and subscribers saw the conversation as RUNNING forever. - shield the wait in close() (same idiom as interrupt()) so a timeout gives up on waiting instead of cancelling the cleanup - swallow a cancellation landing on the drain await itself so the terminal state update is published even when close() cancels a task parked in its drain tail - name both timeouts and derive the close budget from the drain ceiling so the 10-vs-30 mismatch cannot silently reappear Fixes OpenHands#4387 Co-authored-by: openhands <openhands@all-hands.dev>
Collaborator
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
Push an update once this is addressed and this check re-runs automatically. This is an automated check - no AI was used to generate this comment. |
Collaborator
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HUMAN:
Fixes #4387. I hit this while running the agent-server locally — conversations stayed RUNNING in the client after shutdown. Reviewed the fix and test myself.
AGENT:
Why
EventService.close()cancels the run task and awaits it with a bareasyncio.wait_for(..., timeout=10.0), but the task'sfinallyblock firstdrains pending WebSocket callbacks for up to 30 s
(
AsyncCallbackWrapper.wait_for_pending) before publishing the terminalstate snapshot. When the drain outlasts the 10 s budget,
wait_forissues asecond
task.cancel()that lands on the drain await and aborts thefinallymid-flight —_publish_state_update()never runs, andclose()tears down the pub_sub immediately after. Remote clients never receive the
terminal FINISHED/PAUSED/ERROR snapshot and see the conversation as RUNNING
forever. Related structural recurrences of the same 10-vs-30 mismatch:
#3842, #3363.
Summary
close()(asyncio.wait_for(asyncio.shield(...))) —the same idiom
interrupt()already uses — so a timeout gives up onwaiting without force-cancelling the task's cleanup.
_publish_state_update()still runs even whenclose()cancels a taskparked in its drain tail (the shield only prevents the second cancel).
budget from the drain ceiling
(
RUN_TASK_CLOSE_TIMEOUT_SECONDS = RUN_CALLBACK_DRAIN_TIMEOUT_SECONDS + 5.0),so the mismatch cannot silently reappear.
Issue Number
Fixes #4387
How to Test
New deterministic regression test (no long sleeps, ~0.3 s):
It parks a real conversation's run task in its
wait_for_pending()tail,calls
close(), and asserts the terminal_publish_state_update()stillran. Verified red on
main(1 failed in 0.30s— the cancel aborts thefinally before the publish) and green with this fix (
1 passed).Full file:
uv run pytest tests/agent_server/test_event_service.py -q→111 passed in 17.57s, including the pre-existing
test_close_proceeds_on_run_task_timeout(best-effort shutdown semanticspreserved) and
test_close_blocks_until_executor_thread_finishes(close()still returns promptly because cancellation works — only its stale comment
referencing the old 10 s literal was updated).
Pre-commit (ruff format/lint, pycodestyle, pyright, import rules) passes on
both changed files.
Video/Screenshots
N/A — server-internal lifecycle fix; test output above is the evidence.
Type
Notes
AsyncCallbackWrapper.wait_for_pendingtreats
timeoutas per-future rather than a total budget (N stuck futures⇒ N×30 s), and its docstring claims
TimeoutErrorpropagates but the bareexcept Exceptionswallows it. SDK-side change, kept out of this PR tostay focused.
surface. No docs-repo PR needed.