From 8c3526a2632917a303611fc4d80c4126718fdac7 Mon Sep 17 00:00:00 2001 From: cty-ut Date: Fri, 15 May 2026 01:15:11 +0900 Subject: [PATCH 1/2] fix: skip wait_for_status when Vercel sandbox is in a terminal state --- .../extensions/sandbox/vercel/sandbox.py | 29 ++++++--- tests/extensions/sandbox/test_vercel.py | 63 ++++++++++++++++++- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 92513077ab..ba28aba0fb 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -79,6 +79,11 @@ httpx.ProtocolError, ) +# Sandbox status values that can still transition to RUNNING (non-terminal). +# Terminal states (e.g. "stopped", "failed") are not included because a sandbox +# in those states can never become RUNNING, so waiting is futile. +_VERCEL_TRANSIENT_SANDBOX_STATUSES: frozenset[str] = frozenset({"pending", "stopping"}) + def _is_transient_create_error(exc: BaseException) -> bool: if exception_chain_has_status_code(exc, {408, 425, 429, 500, 502, 503, 504}): @@ -754,15 +759,21 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: project_id=resolved_project_id, team_id=resolved_team_id, ) - # XXX(scotttrinh): This will wait even if in a terminal state. - # We should make wait_for_status smarter about the possible - # transitions to avoid waiting for a status if it's impossible - # to transition to it from the current status. - await sandbox.wait_for_status( - SandboxStatus.RUNNING, - timeout=DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S, - ) - reconnected = True + current_status = str(sandbox.status) + if current_status == str(SandboxStatus.RUNNING): + # Already running; skip the wait entirely. + reconnected = True + elif current_status in _VERCEL_TRANSIENT_SANDBOX_STATUSES: + # Still transitioning toward RUNNING; wait normally. + await sandbox.wait_for_status( + SandboxStatus.RUNNING, + timeout=DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S, + ) + reconnected = True + else: + # Terminal state (e.g. "stopped", "failed"): cannot reach RUNNING. + await sandbox.client.aclose() + sandbox = None except TimeoutError: if sandbox is not None: await sandbox.client.aclose() diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 306acf9527..37e9355433 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -793,13 +793,71 @@ async def test_vercel_resume_reconnects_existing_running_sandbox( "team_id": None, } ] + assert resumed._inner.state.sandbox_id == "sandbox-existing" + assert _FakeAsyncSandbox.create_calls == [] + # Sandbox is already RUNNING, so wait_for_status should not be called. + assert existing.wait_for_status_calls == [] + assert resumed._inner._workspace_state_preserved_on_start() is True # noqa: SLF001 + assert resumed._inner._system_state_preserved_on_start() is True # noqa: SLF001 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("transient_status", ["pending", "stopping"]) +async def test_vercel_resume_waits_when_sandbox_in_transient_state( + monkeypatch: pytest.MonkeyPatch, + transient_status: str, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing", status=transient_status) + _FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing + + state = vercel_module.VercelSandboxSessionState( + session_id="00000000-0000-0000-0000-000000000200", + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id=existing.sandbox_id, + ) + + client = vercel_module.VercelSandboxClient() + resumed = await client.resume(state) + assert resumed._inner.state.sandbox_id == "sandbox-existing" assert _FakeAsyncSandbox.create_calls == [] assert existing.wait_for_status_calls == [ ("running", vercel_module.DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S) ] assert resumed._inner._workspace_state_preserved_on_start() is True # noqa: SLF001 - assert resumed._inner._system_state_preserved_on_start() is True # noqa: SLF001 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal_status", ["stopped", "failed"]) +async def test_vercel_resume_recreates_sandbox_when_in_terminal_state( + monkeypatch: pytest.MonkeyPatch, + terminal_status: str, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + existing = _FakeAsyncSandbox(sandbox_id="sandbox-terminal", status=terminal_status) + _FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing + + state = vercel_module.VercelSandboxSessionState( + session_id="00000000-0000-0000-0000-000000000201", + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id=existing.sandbox_id, + ) + + client = vercel_module.VercelSandboxClient() + resumed = await client.resume(state) + + # Should NOT have waited for status — the sandbox is already terminal. + assert existing.wait_for_status_calls == [] + # Client must be closed before abandoning the sandbox. + assert existing.client.closed is True + # A new sandbox must have been created to replace the terminal one. + assert len(_FakeAsyncSandbox.create_calls) == 1 + assert resumed._inner.state.sandbox_id != "sandbox-terminal" + assert resumed._inner.state.workspace_root_ready is False + assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001 @pytest.mark.asyncio @@ -837,7 +895,8 @@ async def test_vercel_resume_recreates_sandbox_after_wait_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) - existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing") + # Use "pending" so that the code enters the wait path (not already RUNNING). + existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing", status="pending") existing.wait_for_status_error = TimeoutError() _FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing From 0a2d5ebeeca98c93fe2e2f577dfb7824fad97a86 Mon Sep 17 00:00:00 2001 From: cty-ut Date: Fri, 15 May 2026 01:34:12 +0900 Subject: [PATCH 2/2] fix: only treat PENDING as transient; STOPPING cannot reach RUNNING --- src/agents/extensions/sandbox/vercel/sandbox.py | 14 ++++++++------ tests/extensions/sandbox/test_vercel.py | 17 ++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index ba28aba0fb..44812c1788 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -79,10 +79,11 @@ httpx.ProtocolError, ) -# Sandbox status values that can still transition to RUNNING (non-terminal). -# Terminal states (e.g. "stopped", "failed") are not included because a sandbox -# in those states can never become RUNNING, so waiting is futile. -_VERCEL_TRANSIENT_SANDBOX_STATUSES: frozenset[str] = frozenset({"pending", "stopping"}) +# Sandbox status values from which the sandbox can still transition to RUNNING. +# Only "pending" qualifies: a freshly created sandbox transitions PENDING -> RUNNING. +# Other non-RUNNING states ("stopping", "stopped", "failed", "aborted", +# "snapshotting") cannot reach RUNNING, so waiting is futile. +_VERCEL_TRANSIENT_SANDBOX_STATUSES: frozenset[str] = frozenset({"pending"}) def _is_transient_create_error(exc: BaseException) -> bool: @@ -764,14 +765,15 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: # Already running; skip the wait entirely. reconnected = True elif current_status in _VERCEL_TRANSIENT_SANDBOX_STATUSES: - # Still transitioning toward RUNNING; wait normally. + # Still transitioning toward RUNNING (e.g. PENDING); wait normally. await sandbox.wait_for_status( SandboxStatus.RUNNING, timeout=DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S, ) reconnected = True else: - # Terminal state (e.g. "stopped", "failed"): cannot reach RUNNING. + # Cannot reach RUNNING from here (STOPPING, STOPPED, FAILED, + # ABORTED, SNAPSHOTTING). Drop the handle and recreate below. await sandbox.client.aclose() sandbox = None except TimeoutError: diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 37e9355433..71c4130b4c 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -802,13 +802,11 @@ async def test_vercel_resume_reconnects_existing_running_sandbox( @pytest.mark.asyncio -@pytest.mark.parametrize("transient_status", ["pending", "stopping"]) -async def test_vercel_resume_waits_when_sandbox_in_transient_state( +async def test_vercel_resume_waits_when_sandbox_pending( monkeypatch: pytest.MonkeyPatch, - transient_status: str, ) -> None: vercel_module = _load_vercel_module(monkeypatch) - existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing", status=transient_status) + existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing", status="pending") _FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing state = vercel_module.VercelSandboxSessionState( @@ -830,11 +828,15 @@ async def test_vercel_resume_waits_when_sandbox_in_transient_state( @pytest.mark.asyncio -@pytest.mark.parametrize("terminal_status", ["stopped", "failed"]) -async def test_vercel_resume_recreates_sandbox_when_in_terminal_state( +@pytest.mark.parametrize( + "terminal_status", ["stopping", "stopped", "failed", "aborted", "snapshotting"] +) +async def test_vercel_resume_recreates_sandbox_when_cannot_reach_running( monkeypatch: pytest.MonkeyPatch, terminal_status: str, ) -> None: + """A sandbox in any state that cannot transition to RUNNING must be recreated + immediately, without waiting for the wait_for_status timeout.""" vercel_module = _load_vercel_module(monkeypatch) existing = _FakeAsyncSandbox(sandbox_id="sandbox-terminal", status=terminal_status) _FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing @@ -849,11 +851,8 @@ async def test_vercel_resume_recreates_sandbox_when_in_terminal_state( client = vercel_module.VercelSandboxClient() resumed = await client.resume(state) - # Should NOT have waited for status — the sandbox is already terminal. assert existing.wait_for_status_calls == [] - # Client must be closed before abandoning the sandbox. assert existing.client.closed is True - # A new sandbox must have been created to replace the terminal one. assert len(_FakeAsyncSandbox.create_calls) == 1 assert resumed._inner.state.sandbox_id != "sandbox-terminal" assert resumed._inner.state.workspace_root_ready is False