From 122e797d2f2b6c617a076a5d96d408c5dded00ac Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 9 Feb 2026 21:04:11 +0000 Subject: [PATCH 01/10] feat: upgrade to Python 3.13 with libtmux race condition fix - Update target-version and pythonVersion to 3.13 in root pyproject.toml - Update Python version in server.yml build matrix to 3.13 - Update Python version in pypi-release.yml to 3.13 - Update Python version in pr-review action to 3.13 - Pin libtmux to neubig/libtmux#fix/new-session-race-condition branch which fixes the race condition in new_session() that causes TmuxObjectDoesNotExist errors in Python 3.13 environments The libtmux fix avoids the race condition by eliminating the separate list-sessions query after session creation, instead parsing the session data directly from the -P output of new-session. Fixes the Python 3.13 + PyInstaller + Docker compatibility issue reported in libtmux#624. Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 2 +- .github/workflows/pypi-release.yml | 4 ++-- .github/workflows/server.yml | 6 ++---- openhands-tools/pyproject.toml | 2 +- pyproject.toml | 4 ++-- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 81f83bb58c..4334b18020 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -62,7 +62,7 @@ runs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Install uv uses: astral-sh/setup-uv@v6 diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 51e1bc6765..b3f33171bf 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -35,7 +35,7 @@ jobs: uses: astral-sh/setup-uv@v7 with: version: latest - python-version: '3.12' + python-version: '3.13' - name: Build and publish all packages env: @@ -128,7 +128,7 @@ jobs: uses: astral-sh/setup-uv@v7 with: version: latest - python-version: '3.12' + python-version: '3.13' - name: Install Poetry run: | diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index da4255bebf..2b6e748b33 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -162,17 +162,15 @@ jobs: # Each job specifies exactly what it builds and where it runs include: # Python variant - # NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility - # issue with Python 3.13. See issue #1886 for details. - variant: python arch: amd64 - base_image: nikolaik/python-nodejs:python3.12-nodejs22 + base_image: nikolaik/python-nodejs:python3.13-nodejs22 runner: ubuntu-24.04 platform: linux/amd64 - variant: python arch: arm64 - base_image: nikolaik/python-nodejs:python3.12-nodejs22 + base_image: nikolaik/python-nodejs:python3.13-nodejs22 runner: ubuntu-24.04-arm platform: linux/arm64 diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 268bf9dcd2..647265d404 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "bashlex>=0.18", "binaryornot>=0.4.4", "cachetools", - "libtmux>=0.53.0", + "libtmux @ git+https://github.com/neubig/libtmux.git@fix/new-session-race-condition", "pydantic>=2.11.7", "browser-use>=0.8.0", "func-timeout>=4.3.5", diff --git a/pyproject.toml b/pyproject.toml index 2f1c150489..0f167f6f72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dev = [ # Ruff configuration [tool.ruff] -target-version = "py312" +target-version = "py313" line-length = 88 [tool.ruff.format] @@ -100,7 +100,7 @@ extraPaths = [ ] venvPath = "." venv = ".venv" -pythonVersion = "3.12" +pythonVersion = "3.13" useLibraryCodeForTypes = true typeCheckingMode = "standard" From d01c055cf68ddc3ceff476847fa6cd244ee2586d Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 09:14:45 +0000 Subject: [PATCH 02/10] fix: use AGENT_SERVER_SHA to avoid conflict with GitHub's default GITHUB_SHA GitHub Actions sets GITHUB_SHA to the merge commit by default, which differs from the PR head commit. Use a custom variable AGENT_SERVER_SHA to explicitly pass the PR head SHA to example scripts for Docker image selection. Co-authored-by: openhands --- .github/workflows/run-examples.yml | 4 +++- .../02_convo_with_docker_sandboxed_server.py | 10 +++++----- .../03_browser_use_with_docker_sandboxed_server.py | 10 +++++----- .../04_convo_with_api_sandboxed_server.py | 6 +++--- .../05_vscode_with_docker_sandboxed_server.py | 10 +++++----- .../08_convo_with_apptainer_sandboxed_server.py | 10 +++++----- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.github/workflows/run-examples.yml b/.github/workflows/run-examples.yml index a280b819e7..5ebfed0401 100644 --- a/.github/workflows/run-examples.yml +++ b/.github/workflows/run-examples.yml @@ -70,7 +70,9 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} REPO_OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} - GITHUB_SHA: ${{ github.event.pull_request.head.sha }} + # Use custom var name to avoid conflict with GitHub's default GITHUB_SHA + # which points to the merge commit, not the PR head commit + AGENT_SERVER_SHA: ${{ github.event.pull_request.head.sha }} OPENHANDS_CLOUD_API_KEY: ${{ secrets.ALLHANDS_BOT_OPENHANDS_SAAS_API_KEY }} run: | RESULTS_DIR=".example-test-results" diff --git a/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py index 9bda66c987..3796ae0dbe 100644 --- a/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py @@ -40,11 +40,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" diff --git a/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py index 2d572cb152..6702d23388 100644 --- a/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py @@ -35,11 +35,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" diff --git a/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py b/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py index 9e78f7171c..6f03234ee6 100644 --- a/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py +++ b/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py @@ -45,9 +45,9 @@ exit(1) -# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency -# Otherwise, use the latest image from main -server_image_sha = os.getenv("GITHUB_SHA") or "main" +# AGENT_SERVER_SHA is set by CI to the PR head commit SHA +# This avoids conflict with GitHub's default GITHUB_SHA (merge commit) +server_image_sha = os.getenv("AGENT_SERVER_SHA") or "main" server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64" logger.info(f"Using server image: {server_image}") diff --git a/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py index ab9a0d59a6..aa6057d359 100644 --- a/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py @@ -37,11 +37,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" diff --git a/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py b/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py index 713d9199d9..69dce2ef8f 100644 --- a/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py +++ b/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py @@ -40,11 +40,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" From cbb660f6931975ef47910ad098f12567680f28e6 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 09:35:22 +0000 Subject: [PATCH 03/10] fix: update uv.lock and simplify Generator type hints for Python 3.13 - Regenerate uv.lock with pinned libtmux git dependency - Simplify Generator[T, None, None] to Generator[T] in test files Co-authored-by: openhands --- tests/cross/test_remote_conversation_live_server.py | 4 +--- tests/sdk/mcp/test_create_mcp_tool.py | 4 ++-- tests/tools/browser_use/test_browser_executor_e2e.py | 4 ++-- uv.lock | 8 ++------ 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py index 9ea4181e76..859cebdfb5 100644 --- a/tests/cross/test_remote_conversation_live_server.py +++ b/tests/cross/test_remote_conversation_live_server.py @@ -34,9 +34,7 @@ @pytest.fixture -def server_env( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> Generator[dict, None, None]: +def server_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[dict]: """Launch a real FastAPI server backed by temp workspace and conversations. We set OPENHANDS_AGENT_SERVER_CONFIG_PATH before creating the app so that diff --git a/tests/sdk/mcp/test_create_mcp_tool.py b/tests/sdk/mcp/test_create_mcp_tool.py index f774a0b141..ce4aad2447 100644 --- a/tests/sdk/mcp/test_create_mcp_tool.py +++ b/tests/sdk/mcp/test_create_mcp_tool.py @@ -110,7 +110,7 @@ def stop(self): @pytest.fixture -def http_mcp_server() -> Generator[MCPTestServer, None, None]: +def http_mcp_server() -> Generator[MCPTestServer]: """Fixture providing a running HTTP MCP server with test tools.""" server = MCPTestServer("http-test-server") @@ -130,7 +130,7 @@ def add_numbers(a: int, b: int) -> int: @pytest.fixture -def sse_mcp_server() -> Generator[MCPTestServer, None, None]: +def sse_mcp_server() -> Generator[MCPTestServer]: """Fixture providing a running SSE MCP server with test tools.""" server = MCPTestServer("sse-test-server") diff --git a/tests/tools/browser_use/test_browser_executor_e2e.py b/tests/tools/browser_use/test_browser_executor_e2e.py index e3d736570f..af70c4790d 100644 --- a/tests/tools/browser_use/test_browser_executor_e2e.py +++ b/tests/tools/browser_use/test_browser_executor_e2e.py @@ -103,7 +103,7 @@ @pytest.fixture(scope="module") -def test_server() -> Generator[str, None, None]: +def test_server() -> Generator[str]: """Set up a local HTTP server for testing.""" temp_dir = tempfile.mkdtemp() server_process = None @@ -144,7 +144,7 @@ def test_server() -> Generator[str, None, None]: @pytest.fixture -def browser_executor() -> Generator[BrowserToolExecutor, None, None]: +def browser_executor() -> Generator[BrowserToolExecutor]: """Create a real BrowserToolExecutor for testing.""" executor = None try: diff --git a/uv.lock b/uv.lock index 978c37e6fb..f59f3456a7 100644 --- a/uv.lock +++ b/uv.lock @@ -1547,11 +1547,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.53.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/28/e2b252817cb181aec2f42fe2d1d7fac5ec9c4d15bfb2b8ea4bd1179e4244/libtmux-0.53.0.tar.gz", hash = "sha256:1d19af4cea0c19543954d7e7317c7025c0739b029cccbe3b843212fae238f1bd", size = 405001, upload-time = "2025-12-14T11:59:11.337Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/d0/2e8bc5caa639ebb9f8801ba0be7070a28d48d8ed60e2a428d40f71fb88b8/libtmux-0.53.0-py3-none-any.whl", hash = "sha256:024b7ae6a12aae55358e8feb914c8632b3ab9bd61c0987c53559643c6a58ee4f", size = 77582, upload-time = "2025-12-14T11:59:09.739Z" }, -] +source = { git = "https://github.com/neubig/libtmux.git?rev=fix%2Fnew-session-race-condition#6d202e082030efb04074b9231c152510386aebca" } [[package]] name = "litellm" @@ -2205,7 +2201,7 @@ requires-dist = [ { name = "browser-use", specifier = ">=0.8.0" }, { name = "cachetools" }, { name = "func-timeout", specifier = ">=4.3.5" }, - { name = "libtmux", specifier = ">=0.53.0" }, + { name = "libtmux", git = "https://github.com/neubig/libtmux.git?rev=fix%2Fnew-session-race-condition" }, { name = "openhands-sdk", editable = "openhands-sdk" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "tom-swe", specifier = ">=1.0.3" }, From 252274ce249d8eb5aed20cc2c1e5686cb8875686 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 00:40:42 +0000 Subject: [PATCH 04/10] fix: use GITHUB_SHA env var for Docker image tagging in server.yml The SHORT_SHA extraction was using github.sha (merge commit) instead of the GITHUB_SHA env var (which is set to PR head SHA for pull_request events). This caused Docker images to be tagged with the wrong SHA, making them unfindable by run-examples.yml which uses the PR head SHA. Co-authored-by: openhands --- .github/workflows/server.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 2b6e748b33..6d35a65194 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -253,8 +253,9 @@ jobs: TAGS=$(grep '^tags_csv=' $GITHUB_OUTPUT | cut -d= -f2-) echo "tags=$TAGS" >> $GITHUB_OUTPUT - # Extract short SHA for consolidation - SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + # Extract short SHA for consolidation (use GITHUB_SHA env var, not github.sha) + # GITHUB_SHA is set to PR head SHA for pull_request events + SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7) echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT # Extract versioned tags CSV for consolidation From dc0581a38019b9b8ee9bfcc5213089f75001cc8c Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 00:48:29 +0000 Subject: [PATCH 05/10] fix: simplify Generator/AsyncGenerator type hints for Python 3.13 Co-authored-by: openhands --- openhands-sdk/openhands/sdk/agent/base.py | 2 +- openhands-sdk/openhands/sdk/utils/paging.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/base.py b/openhands-sdk/openhands/sdk/agent/base.py index 20e0ad6c85..e8f299f0e5 100644 --- a/openhands-sdk/openhands/sdk/agent/base.py +++ b/openhands-sdk/openhands/sdk/agent/base.py @@ -426,7 +426,7 @@ def model_dump_succint(self, **kwargs): dumped["tools"] = list(dumped["tools"].keys()) return dumped - def get_all_llms(self) -> Generator[LLM, None, None]: + def get_all_llms(self) -> Generator[LLM]: """Recursively yield unique *base-class* LLM objects reachable from `self`. - Returns actual object references (not copies). diff --git a/openhands-sdk/openhands/sdk/utils/paging.py b/openhands-sdk/openhands/sdk/utils/paging.py index bc196e8f69..40bd68c13f 100644 --- a/openhands-sdk/openhands/sdk/utils/paging.py +++ b/openhands-sdk/openhands/sdk/utils/paging.py @@ -20,7 +20,7 @@ async def page_iterator[T]( search_func: Callable[..., Awaitable[PageProtocol[T]]], *args: Any, **kwargs: Any, -) -> AsyncGenerator[T, None]: +) -> AsyncGenerator[T]: """ Iterate over items from paginated search results. From 987ed320b082db8852f16b53f0ab5534cdaa33a5 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 00:55:42 +0000 Subject: [PATCH 06/10] fix: use SDK_SHA instead of GITHUB_SHA for Docker image tagging GITHUB_SHA is a reserved GitHub Actions environment variable that cannot be overridden at job level. Use SDK_SHA which is already supported by build.py and takes precedence over GITHUB_SHA. This ensures Docker images are tagged with the PR head SHA (not the merge commit SHA) so run-examples.yml can find the correct images. Co-authored-by: openhands --- .github/workflows/server.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 6d35a65194..223baf95d7 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -210,9 +210,10 @@ jobs: ARCH: ${{ matrix.arch }} TARGET: binary PLATFORM: ${{ matrix.platform }} - # Use PR head SHA for pull requests to match the image tag expected by run-examples.yml - GITHUB_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - GITHUB_REF: ${{ github.ref }} + # Use SDK_SHA to pass PR head SHA since GITHUB_SHA is a reserved GitHub variable + # that cannot be overridden at job level + SDK_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SDK_REF: ${{ github.ref }} CI: 'true' steps: @@ -238,10 +239,15 @@ jobs: - name: Prepare build context and metadata id: prep run: | + # Debug: verify SDK_SHA is set correctly + echo "DEBUG: SDK_SHA env var = $SDK_SHA" + echo "DEBUG: Expected PR head SHA for pull requests" + uv sync --frozen # Generate build context and tags with arch suffix # build.py now handles architecture tagging internally via --arch flag + # build.py reads SDK_SHA from environment to get the correct SHA # Add --versioned-tag when triggered by a git tag (e.g., v1.0.0) BUILD_CMD="uv run ./openhands-agent-server/openhands/agent_server/docker/build.py --build-ctx-only --arch ${{ matrix.arch }}" if [[ "${{ github.ref }}" == refs/tags/* ]]; then @@ -253,9 +259,9 @@ jobs: TAGS=$(grep '^tags_csv=' $GITHUB_OUTPUT | cut -d= -f2-) echo "tags=$TAGS" >> $GITHUB_OUTPUT - # Extract short SHA for consolidation (use GITHUB_SHA env var, not github.sha) - # GITHUB_SHA is set to PR head SHA for pull_request events - SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7) + # Extract short SHA for consolidation (use SDK_SHA env var) + # SDK_SHA is set to PR head SHA for pull_request events + SHORT_SHA=$(echo $SDK_SHA | cut -c1-7) echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT # Extract versioned tags CSV for consolidation From 3afcff9c9c07268054355f6d20c77eb300fc2590 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 01:27:11 +0000 Subject: [PATCH 07/10] fix: update Dockerfile builder to Python 3.13 for libtmux fix The libtmux race condition fix (libtmux#625) requires Python 3.13 environment throughout the build process, not just in the base image. Previously, the builder stage was using Python 3.12, which meant dependencies were compiled for Python 3.12 even though the runtime image used Python 3.13. Changes: - Update builder FROM to python:3.13-bookworm - Update uv python install/venv from 3.12 to 3.13 - Update ARG BASE_IMAGE default to python3.13-nodejs22 - Update comment to reflect libtmux fix status Co-authored-by: openhands --- .../openhands/agent_server/docker/Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/docker/Dockerfile b/openhands-agent-server/openhands/agent_server/docker/Dockerfile index 7684064ff2..361e11b727 100644 --- a/openhands-agent-server/openhands/agent_server/docker/Dockerfile +++ b/openhands-agent-server/openhands/agent_server/docker/Dockerfile @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.7 -# NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility issue -# with Python 3.13. See issue #1886 for details. -ARG BASE_IMAGE=nikolaik/python-nodejs:python3.12-nodejs22 +# NOTE: Python 3.13 is used with libtmux pinned to neubig's branch that fixes +# the race condition in new_session(). See libtmux#624 and libtmux#625. +ARG BASE_IMAGE=nikolaik/python-nodejs:python3.13-nodejs22 ARG USERNAME=openhands ARG UID=10001 ARG GID=10001 @@ -12,7 +12,7 @@ ARG PORT=8000 # Builder (source mode) # We copy source + build a venv here for local dev and debugging. #################################################################################### -FROM python:3.12-bullseye AS builder +FROM python:3.13-bookworm AS builder ARG USERNAME UID GID ENV UV_PROJECT_ENVIRONMENT=/agent-server/.venv ENV UV_PYTHON_INSTALL_DIR=/agent-server/uv-managed-python @@ -30,7 +30,7 @@ COPY --chown=${USERNAME}:${USERNAME} openhands-tools ./openhands-tools COPY --chown=${USERNAME}:${USERNAME} openhands-workspace ./openhands-workspace COPY --chown=${USERNAME}:${USERNAME} openhands-agent-server ./openhands-agent-server RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \ - uv python install 3.12 && uv venv --python 3.12 .venv && uv sync --frozen --no-editable --managed-python + uv python install 3.13 && uv venv --python 3.13 .venv && uv sync --frozen --no-editable --managed-python #################################################################################### # Binary Builder (binary mode) From 8d5387f8f2017fb645393f80999df742c580bcdf Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 01:52:46 +0000 Subject: [PATCH 08/10] fix: add workaround for libtmux session_id race condition When libtmux's new_session() returns a Session with session_id=None (due to a bug in parse_output() not handling fewer output values), we now retry fetching the session from server.sessions. This is a workaround for the issue where neubig's fix PR #625 doesn't fully handle cases where tmux returns fewer than the expected 125 format field values, causing the zip() to truncate before reaching session_id at index 92. The workaround: 1. Check if session_id is None after new_session() 2. If so, retry up to 3 times with increasing delays 3. Fetch the session by name from server.sessions 4. Raise a clear error if all retries fail See: https://github.com/tmux-python/libtmux/issues/624 Co-authored-by: openhands --- .../tools/terminal/terminal/tmux_terminal.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py index d41b8ee9db..67a59ece19 100644 --- a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py +++ b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py @@ -59,6 +59,34 @@ def initialize(self) -> None: x=1000, y=1000, ) + + # Workaround for libtmux race condition: if session_id is None after + # new_session(), retry fetching it. This can happen when libtmux's + # parse_output() fails to extract all fields from tmux output. + # See: https://github.com/tmux-python/libtmux/issues/624 + if self.session.session_id is None: + for attempt in range(3): + time.sleep(0.1 * (attempt + 1)) + try: + # Try to refresh session data from tmux + sessions = self.server.sessions.filter(session_name=session_name) + if sessions: + self.session = sessions[0] + if self.session.session_id is not None: + logger.debug( + f"Session ID resolved after {attempt + 1} attempts" + ) + break + except Exception as e: + logger.debug(f"Retry {attempt + 1} failed: {e}") + + if self.session.session_id is None: + raise RuntimeError( + f"Failed to get session_id for session '{session_name}'. " + "This may be a libtmux race condition. " + "See https://github.com/tmux-python/libtmux/issues/624" + ) + for k, v in env.items(): self.session.set_environment(k, v) From 666c4204cbfcfe45de85b5b59bb7e4ef41ba435a Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 02:14:33 +0000 Subject: [PATCH 09/10] fix: improve libtmux race condition workaround with full retry logic Improved the workaround for the libtmux race condition where new_session() returns a Session with session_id=None. Changes: - Wrap entire session creation in retry loop (up to 5 attempts) - Try to get session from server.sessions if session_id is None - Add increasing delays between retries - Clean up orphan sessions before retry - Better error messages with last error included The root issue is that neubig's PR #625 fix tries to parse 125+ format fields from tmux output, but tmux may not output all fields in some environments (especially Python 3.13 + PyInstaller + Docker), causing session_id (at index 92) to be missing from the parsed output. Co-authored-by: openhands --- .../tools/terminal/terminal/tmux_terminal.py | 82 ++++++++++++------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py index 67a59ece19..999dff1078 100644 --- a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py +++ b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py @@ -52,40 +52,60 @@ def initialize(self) -> None: logger.debug(f"Initializing tmux terminal with command: {window_command}") session_name = f"openhands-{self.username}-{uuid.uuid4()}" - self.session = self.server.new_session( - session_name=session_name, - start_directory=self.work_dir, - kill_session=True, - x=1000, - y=1000, - ) - # Workaround for libtmux race condition: if session_id is None after - # new_session(), retry fetching it. This can happen when libtmux's - # parse_output() fails to extract all fields from tmux output. - # See: https://github.com/tmux-python/libtmux/issues/624 - if self.session.session_id is None: - for attempt in range(3): + # Workaround for libtmux race condition (https://github.com/tmux-python/libtmux/issues/624): + # The fix in neubig's PR #625 tries to parse 125+ fields from tmux output, + # but tmux may not output all fields, causing session_id to be missing. + # We create the session and retry getting session data if session_id is None. + max_retries = 5 + last_error = None + + for attempt in range(max_retries): + try: + self.session = self.server.new_session( + session_name=session_name, + start_directory=self.work_dir, + kill_session=True, + x=1000, + y=1000, + ) + + if self.session.session_id is not None: + break + + # session_id is None - try to get it from server.sessions + # Add a small delay to let tmux register the session time.sleep(0.1 * (attempt + 1)) + sessions = self.server.sessions.filter(session_name=session_name) + if sessions and sessions[0].session_id is not None: + self.session = sessions[0] + logger.debug( + f"Session ID resolved via list-sessions after " + f"{attempt + 1} attempts" + ) + break + + except Exception as e: + last_error = e + # If session was created but we couldn't get session_id, + # try to kill it before retrying try: - # Try to refresh session data from tmux - sessions = self.server.sessions.filter(session_name=session_name) - if sessions: - self.session = sessions[0] - if self.session.session_id is not None: - logger.debug( - f"Session ID resolved after {attempt + 1} attempts" - ) - break - except Exception as e: - logger.debug(f"Retry {attempt + 1} failed: {e}") - - if self.session.session_id is None: - raise RuntimeError( - f"Failed to get session_id for session '{session_name}'. " - "This may be a libtmux race condition. " - "See https://github.com/tmux-python/libtmux/issues/624" - ) + for s in self.server.sessions.filter(session_name=session_name): + s.kill() + except Exception: + pass + time.sleep(0.2 * (attempt + 1)) + continue + + if self.session.session_id is None: + error_msg = ( + f"Failed to get session_id for session '{session_name}' " + f"after {max_retries} attempts. " + ) + if last_error: + error_msg += f"Last error: {last_error}. " + error_msg += "See https://github.com/tmux-python/libtmux/issues/624" + raise RuntimeError(error_msg) for k, v in env.items(): self.session.set_environment(k, v) From 40e712f66eda4e30e8fae41b8052bbae2d4b4605 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 23:56:31 +0000 Subject: [PATCH 10/10] fix: add direct tmux fallback for session_id resolution The libtmux fix (neubig's PR #625) uses a format string with 125+ fields, but tmux may not output all fields correctly in some environments (Python 3.13 + PyInstaller + Docker), causing session_id (at index 92) to be missing from the parsed output. This adds a fallback that directly queries tmux using a simple format string ('#{session_id}:#{session_name}') to get the session_id when libtmux's complex format parsing fails. Co-authored-by: openhands --- .../tools/terminal/terminal/tmux_terminal.py | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py index 999dff1078..4aec5bae53 100644 --- a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py +++ b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py @@ -1,5 +1,6 @@ """Tmux-based terminal backend implementation.""" +import subprocess import time import uuid @@ -15,6 +16,46 @@ logger = get_logger(__name__) +def _get_session_id_directly(session_name: str) -> str | None: + """Get session_id directly from tmux using a simple format string. + + This is a fallback for when libtmux's complex format parsing fails. + The issue is that libtmux's new_session() uses a format string with 125+ + fields, and if tmux doesn't output all fields correctly, the session_id + (at index 92) may be missing from the parsed output. + + This function uses a simple tmux command to get just the session_id. + """ + try: + result = subprocess.run( + [ + "tmux", + "list-sessions", + "-F", + "#{session_id}:#{session_name}", + ], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + logger.debug(f"tmux list-sessions failed: {result.stderr}") + return None + + for line in result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split(":", 1) + if len(parts) == 2 and parts[1] == session_name: + session_id = parts[0] + if session_id.startswith("$"): + return session_id + return None + except Exception as e: + logger.debug(f"Failed to get session_id directly: {e}") + return None + + class TmuxTerminal(TerminalInterface): """Tmux-based terminal backend. @@ -73,9 +114,22 @@ def initialize(self) -> None: if self.session.session_id is not None: break - # session_id is None - try to get it from server.sessions + # session_id is None - try multiple fallback approaches # Add a small delay to let tmux register the session time.sleep(0.1 * (attempt + 1)) + + # Fallback 1: Try to get session_id directly via simple tmux command + # This bypasses libtmux's complex format parsing + direct_session_id = _get_session_id_directly(session_name) + if direct_session_id: + self.session.session_id = direct_session_id + logger.debug( + f"Session ID resolved via direct tmux command after " + f"{attempt + 1} attempts: {direct_session_id}" + ) + break + + # Fallback 2: Try to get it from server.sessions (uses same parsing) sessions = self.server.sessions.filter(session_name=session_name) if sessions and sessions[0].session_id is not None: self.session = sessions[0]