Skip to content

Conversation

@simonrosenberg
Copy link
Collaborator

@simonrosenberg simonrosenberg commented Jan 9, 2026

Summary

Fixes #1633

The bash command polling loop was using time.sleep(0.1) which blocks the event loop in async contexts. This caused the polling to stop prematurely after only 2 attempts, leading to agent loops hanging indefinitely with 20-minute idle timeouts.

Root Cause

The _execute_command_generator method in RemoteWorkspaceMixin used synchronous time.sleep(0.1) between polling attempts. In async contexts (when using AsyncRemoteWorkspace), this blocked the event loop, causing issues with the HTTP client and preventing proper polling continuation.

Solution

Modified the generator to yield sleep requests instead of calling time.sleep() directly. The _execute methods in both sync and async workspace implementations now handle these sleep requests appropriately:

  • Sync (RemoteWorkspace): Uses time.sleep() for sleep requests
  • Async (AsyncRemoteWorkspace): Uses await asyncio.sleep() for sleep requests

This ensures the polling loop continues correctly in both sync and async contexts without blocking the event loop.

Changes

  • openhands-sdk/openhands/sdk/workspace/remote/remote_workspace_mixin.py:

    • Modified _execute_command_generator to yield {"_sleep": duration} requests instead of calling time.sleep() directly
    • Added POLL_INTERVAL_SECONDS constant for polling interval configuration
  • openhands-sdk/openhands/sdk/workspace/remote/base.py:

    • Updated _execute method to handle sleep requests with time.sleep()
  • openhands-sdk/openhands/sdk/workspace/remote/async_remote_workspace.py:

    • Updated _execute method to handle sleep requests with await asyncio.sleep()

Testing

  • Updated existing polling loop tests to reflect new generator behavior
  • Added new test test_async_execute_handles_sleep_requests to verify async sleep handling
  • All 78 workspace tests pass

@simonrosenberg can click here to continue refining the PR


Agent Server images for this PR

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 amd64, arm64 nikolaik/python-nodejs:python3.12-nodejs22 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:657cb46-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:657cb46-golang-amd64
ghcr.io/openhands/agent-server:657cb46-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:657cb46-golang-arm64
ghcr.io/openhands/agent-server:657cb46-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:657cb46-java-amd64
ghcr.io/openhands/agent-server:657cb46-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:657cb46-java-arm64
ghcr.io/openhands/agent-server:657cb46-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:657cb46-python-amd64
ghcr.io/openhands/agent-server:657cb46-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-amd64
ghcr.io/openhands/agent-server:657cb46-python-arm64
ghcr.io/openhands/agent-server:657cb46-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-arm64
ghcr.io/openhands/agent-server:657cb46-golang
ghcr.io/openhands/agent-server:657cb46-java
ghcr.io/openhands/agent-server:657cb46-python

About Multi-Architecture Support

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

The bash command polling loop was using time.sleep(0.1) which blocks the
event loop in async contexts. This caused the polling to stop prematurely
after only 2 attempts, leading to agent loops hanging indefinitely.

Changes:
- Modified _execute_command_generator to yield sleep requests instead of
  calling time.sleep() directly
- Updated sync _execute method in RemoteWorkspace to handle sleep requests
  with time.sleep()
- Updated async _execute method in AsyncRemoteWorkspace to handle sleep
  requests with asyncio.sleep()
- Added test for async sleep handling
- Updated existing polling tests to reflect new generator behavior

Fixes #1633

Co-authored-by: openhands <[email protected]>
@github-actions
Copy link
Contributor

github-actions bot commented Jan 9, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/workspace/remote
   async_remote_workspace.py681282%26–31, 141–143, 157–159
   base.py691282%41–46, 156–158, 172–174
   remote_workspace_mixin.py109991%298, 304–307, 325, 331–333
TOTAL14986441870% 

@simonrosenberg simonrosenberg requested a review from tofarr January 9, 2026 11:37
@simonrosenberg
Copy link
Collaborator Author

@OpenHands
read the issue linked to the PR and the PR diff
Pay very close attention to the logic - are there any flaws? Is the code clean or could we find a simpler more elegant solution?
/codereview-roasted

@openhands-ai
Copy link

openhands-ai bot commented Jan 9, 2026

I'm on it! simonrosenberg can track my progress at all-hands.dev

@openhands-ai
Copy link

openhands-ai bot commented Jan 9, 2026

Code Review Summary for PR #1662

🟡 Taste Rating: Acceptable - Works but could be cleaner

The PR correctly fixes the bug (Issue #1633) where bash command polling stopped after 2 attempts due to time.sleep() blocking the event loop in async contexts.


Critical Feedback

Main Issue: Magic String Protocol

The solution uses {"_sleep": POLL_INTERVAL_SECONDS} to signal sleep requests - this is in-band signaling with a magic key that mixes two fundamentally different message types in the same channel.

Recommended Improvement:

from dataclasses import dataclass

@dataclass(frozen=True)
class SleepRequest:
    duration: float

# In generator:
yield SleepRequest(POLL_INTERVAL_SECONDS)

# In executor:
if isinstance(request, SleepRequest):
    await asyncio.sleep(request.duration)
    request = generator.send(None)
else:
    response = await self.client.request(**request)
    request = generator.send(response)

This approach is:

  • Type-safe (no magic strings)
  • Self-documenting
  • Prevents potential key conflicts
  • Makes the protocol explicit rather than implicit

Verdict

Worth merging - The fix solves the real problem and doesn't break anything. The tests properly cover the new behavior.

However, I'd recommend refactoring to use a proper SleepRequest type instead of the magic _sleep key for cleaner, more maintainable code.

Key Insight: The solution works but introduces an implicit protocol (magic _sleep key) where an explicit one (typed SleepRequest class) would be cleaner and prevent future bugs. "Good taste" would be making the two message types distinguishable by type, not by inspecting dict keys.

View full conversation

Copy link
Contributor

@neubig neubig left a comment

Choose a reason for hiding this comment

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

I looked at this but would probably want to defer to an expert on that one.

return client

async def _execute(self, generator: Generator[dict[str, Any], httpx.Response, Any]):
async def _execute(self, generator: Generator[dict[str, Any], Any, Any]):
Copy link
Contributor

Choose a reason for hiding this comment

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

In general Any is not great, should this be httpx.Response | None?

return client

def _execute(self, generator: Generator[dict[str, Any], httpx.Response, Any]):
def _execute(self, generator: Generator[dict[str, Any], Any, Any]):
Copy link
Contributor

Choose a reason for hiding this comment

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

Ditto

@simonrosenberg simonrosenberg marked this pull request as draft January 9, 2026 13:40
@simonrosenberg
Copy link
Collaborator Author

switching back to draft because swebench failed

@simonrosenberg simonrosenberg requested a review from enyst January 9, 2026 14:35
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.

Bug: Bash command polling stops after 2 attempts, causing agent loop to hang

4 participants