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:
Rohit Malhotra
2026-01-08 12:08:11 -08:00
committed by GitHub
parent adfabe7659
commit 5fb431bcc5
19 changed files with 1567 additions and 198 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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.