mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: Implement Slack V1 integration following GitHub V1 pattern (#11825)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.agent_server.models import OpenHandsModel, SendMessageRequest
|
||||
from openhands.agent_server.utils import OpenHandsUUID, utc_now
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
EventCallbackProcessor,
|
||||
@@ -14,7 +14,6 @@ from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.utils.models import OpenHandsModel
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import zipfile
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from time import time
|
||||
from typing import Any, AsyncGenerator, Sequence
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -477,7 +476,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
self, task: AppConversationStartTask
|
||||
) -> AsyncGenerator[AppConversationStartTask, None]:
|
||||
"""Wait for sandbox to start and return info."""
|
||||
# Get the sandbox
|
||||
# Get or create the sandbox
|
||||
if not task.request.sandbox_id:
|
||||
sandbox = await self.sandbox_service.start_sandbox()
|
||||
task.sandbox_id = sandbox.id
|
||||
@@ -489,45 +488,34 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
raise SandboxError(f'Sandbox not found: {task.request.sandbox_id}')
|
||||
sandbox = sandbox_info
|
||||
|
||||
# Update the listener
|
||||
# Update the listener with sandbox info
|
||||
task.status = AppConversationStartTaskStatus.WAITING_FOR_SANDBOX
|
||||
task.sandbox_id = sandbox.id
|
||||
yield task
|
||||
|
||||
# Resume if paused
|
||||
if sandbox.status == SandboxStatus.PAUSED:
|
||||
await self.sandbox_service.resume_sandbox(sandbox.id)
|
||||
|
||||
# Check for immediate error states
|
||||
if sandbox.status in (None, SandboxStatus.ERROR):
|
||||
raise SandboxError(f'Sandbox status: {sandbox.status}')
|
||||
if sandbox.status == SandboxStatus.RUNNING:
|
||||
# There are still bugs in the remote runtime - they report running while still just
|
||||
# starting resulting in a race condition. Manually check that it is actually
|
||||
# running.
|
||||
if await self._check_agent_server_alive(sandbox):
|
||||
return
|
||||
if sandbox.status != SandboxStatus.STARTING:
|
||||
|
||||
# For non-STARTING/RUNNING states (except PAUSED which we just resumed), fail fast
|
||||
if sandbox.status not in (
|
||||
SandboxStatus.STARTING,
|
||||
SandboxStatus.RUNNING,
|
||||
SandboxStatus.PAUSED,
|
||||
):
|
||||
raise SandboxError(f'Sandbox not startable: {sandbox.id}')
|
||||
|
||||
start = time()
|
||||
while time() - start <= self.sandbox_startup_timeout:
|
||||
await asyncio.sleep(self.sandbox_startup_poll_frequency)
|
||||
sandbox_info = await self.sandbox_service.get_sandbox(sandbox.id)
|
||||
if sandbox_info is None:
|
||||
raise SandboxError(f'Sandbox not found: {sandbox.id}')
|
||||
if sandbox.status not in (SandboxStatus.STARTING, SandboxStatus.RUNNING):
|
||||
raise SandboxError(f'Sandbox not startable: {sandbox.id}')
|
||||
if sandbox_info.status == SandboxStatus.RUNNING:
|
||||
# There are still bugs in the remote runtime - they report running while still just
|
||||
# starting resulting in a race condition. Manually check that it is actually
|
||||
# running.
|
||||
if await self._check_agent_server_alive(sandbox_info):
|
||||
return
|
||||
raise SandboxError(f'Sandbox failed to start: {sandbox.id}')
|
||||
|
||||
async def _check_agent_server_alive(self, sandbox_info: SandboxInfo) -> bool:
|
||||
agent_server_url = self._get_agent_server_url(sandbox_info)
|
||||
url = f'{agent_server_url.rstrip("/")}/alive'
|
||||
response = await self.httpx_client.get(url)
|
||||
return response.is_success
|
||||
# Use shared wait_for_sandbox_running utility to poll for ready state
|
||||
await self.sandbox_service.wait_for_sandbox_running(
|
||||
sandbox.id,
|
||||
timeout=self.sandbox_startup_timeout,
|
||||
poll_interval=self.sandbox_startup_poll_frequency,
|
||||
httpx_client=self.httpx_client,
|
||||
)
|
||||
|
||||
def _get_agent_server_url(self, sandbox: SandboxInfo) -> str:
|
||||
"""Get agent server url for running sandbox."""
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import asyncio
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.app_server.errors import SandboxError
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
AGENT_SERVER,
|
||||
SandboxInfo,
|
||||
SandboxPage,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.services.injector import Injector
|
||||
from openhands.app_server.utils.docker_utils import (
|
||||
replace_localhost_hostname_for_docker,
|
||||
)
|
||||
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
from openhands.sdk.utils.paging import page_iterator
|
||||
|
||||
@@ -56,6 +64,96 @@ class SandboxService(ABC):
|
||||
Return False if the sandbox did not exist.
|
||||
"""
|
||||
|
||||
async def wait_for_sandbox_running(
|
||||
self,
|
||||
sandbox_id: str,
|
||||
timeout: int = 120,
|
||||
poll_interval: int = 2,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
) -> SandboxInfo:
|
||||
"""Wait for a sandbox to reach RUNNING status with an alive agent server.
|
||||
|
||||
This method polls the sandbox status until it reaches RUNNING state and
|
||||
optionally verifies the agent server is responding to health checks.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID to wait for
|
||||
timeout: Maximum time to wait in seconds (default: 120)
|
||||
poll_interval: Time between status checks in seconds (default: 2)
|
||||
httpx_client: Optional httpx client for agent server health checks.
|
||||
If provided, will verify the agent server /alive endpoint responds
|
||||
before returning.
|
||||
|
||||
Returns:
|
||||
SandboxInfo with RUNNING status and verified agent server
|
||||
|
||||
Raises:
|
||||
SandboxError: If sandbox not found, enters ERROR state, or times out
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start <= timeout:
|
||||
sandbox = await self.get_sandbox(sandbox_id)
|
||||
if sandbox is None:
|
||||
raise SandboxError(f'Sandbox not found: {sandbox_id}')
|
||||
|
||||
if sandbox.status == SandboxStatus.ERROR:
|
||||
raise SandboxError(f'Sandbox entered error state: {sandbox_id}')
|
||||
|
||||
if sandbox.status == SandboxStatus.RUNNING:
|
||||
# Optionally verify agent server is alive to avoid race conditions
|
||||
# where sandbox reports RUNNING but agent server isn't ready yet
|
||||
if httpx_client and sandbox.exposed_urls:
|
||||
if await self._check_agent_server_alive(sandbox, httpx_client):
|
||||
return sandbox
|
||||
# Agent server not ready yet, continue polling
|
||||
else:
|
||||
return sandbox
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
raise SandboxError(f'Sandbox failed to start within {timeout}s: {sandbox_id}')
|
||||
|
||||
async def _check_agent_server_alive(
|
||||
self, sandbox: SandboxInfo, httpx_client: httpx.AsyncClient
|
||||
) -> bool:
|
||||
"""Check if the agent server is responding to health checks.
|
||||
|
||||
Args:
|
||||
sandbox: The sandbox info containing exposed URLs
|
||||
httpx_client: HTTP client to make the health check request
|
||||
|
||||
Returns:
|
||||
True if agent server is alive, False otherwise
|
||||
"""
|
||||
try:
|
||||
agent_server_url = self._get_agent_server_url(sandbox)
|
||||
url = f'{agent_server_url.rstrip("/")}/alive'
|
||||
response = await httpx_client.get(url, timeout=5.0)
|
||||
return response.is_success
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_agent_server_url(self, sandbox: SandboxInfo) -> str:
|
||||
"""Get agent server URL from sandbox exposed URLs.
|
||||
|
||||
Args:
|
||||
sandbox: The sandbox info containing exposed URLs
|
||||
|
||||
Returns:
|
||||
The agent server URL
|
||||
|
||||
Raises:
|
||||
SandboxError: If no agent server URL is found
|
||||
"""
|
||||
if not sandbox.exposed_urls:
|
||||
raise SandboxError(f'No exposed URLs for sandbox: {sandbox.id}')
|
||||
|
||||
for exposed_url in sandbox.exposed_urls:
|
||||
if exposed_url.name == AGENT_SERVER:
|
||||
return replace_localhost_hostname_for_docker(exposed_url.url)
|
||||
|
||||
raise SandboxError(f'No agent server URL found for sandbox: {sandbox.id}')
|
||||
|
||||
@abstractmethod
|
||||
async def pause_sandbox(self, sandbox_id: str) -> bool:
|
||||
"""Begin the process of pausing a sandbox.
|
||||
|
||||
Reference in New Issue
Block a user