mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* feat(sandbox): share sandbox identity derivation and acquire serialization (#4741) Remote providers (AIO, E2B, BoxLite, Tenki, OpenSandbox) each inlined the same sha256(user:thread)[:16] sandbox-id expression and kept per-scope lock dicts that grew unboundedly until shutdown. This extracts both mechanisms into shared components without changing provider lifecycle, ids, capacity semantics, or public tool behavior: - sandbox/identity.py: keyword-only derive_sandbox_scope_token (byte-pinned compatibility contract) + is_sandbox_scope_token; per-provider golden vectors pin current behavior including BoxLite's raw-None quirk and each provider's private user_id resolution. - sandbox/acquire_serialization.py: AcquireSerializer — per-key lock table with holder/waiter refcount reclamation, bounded dedicated executor (async waits off both the event loop and the default executor), worker-owned cancellation cleanup (no event-loop callback dependency), idempotent close(). - Each provider adopts both components; AIO/E2B key by (user_id, thread_id) with acquire and (E2B) release serialized; BoxLite/Tenki/OpenSandbox key by derived sandbox id and offload the whole sync acquire to the serializer's executor so a cancelled awaiter cannot overlap a retried same-scope body (leaked-remote-VM regression caught in review). - thread_id=None acquires stay unserialized; provider shutdown()/reset() close the serializer; E2B capacity/ledger/reconciliation and AIO ownership/flock machinery untouched. - blocking-IO anchor proves contended OpenSandbox acquire_async stays off the event loop (teeth verified red/green); AGENTS.md documents the shared components. * refactor(sandbox): address review on acquire serialization (#5089) - Replace unreachable checkin branch with an assertion: run() returns False only after abandon(), which the except handler always re-raises; the old _checkin would have double-decremented the refcount. - Document the task.cancelling() == 0 assumption in hold_async. - Drop unused thread_id/user_id kwargs from BoxLite and Tenki _acquire_scope_locked (OpenSandbox still forwards them). * fix(sandbox): preserve request ContextVars in acquire executor bridge (#5089) loop.run_in_executor() does not copy contextvars, unlike the inherited SandboxProvider.acquire_async() which used asyncio.to_thread(). The BoxLite/OpenSandbox/Tenki acquire_async bridges introduced in this PR therefore dropped the request trace id (logged as trace_id=-). Add AcquireSerializer.run_on_executor(), which copies the calling context and runs the callable through ctx.run, and route all three providers through it. Add regression tests binding request_trace_context and verifying the worker thread observes it.
399 lines
18 KiB
Python
399 lines
18 KiB
Python
"""OpenSandbox-backed community ``SandboxProvider`` for DeerFlow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import ipaddress
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from datetime import timedelta
|
|
from functools import partial
|
|
from typing import TYPE_CHECKING, Any
|
|
from urllib.parse import urlsplit
|
|
|
|
from deerflow.config import get_app_config
|
|
from deerflow.sandbox.acquire_serialization import AcquireSerializer
|
|
from deerflow.sandbox.identity import derive_sandbox_scope_token
|
|
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
|
|
from deerflow.sandbox.sandbox_provider import SandboxProvider
|
|
|
|
from ..warm_pool_lifecycle import WarmPoolLifecycleMixin
|
|
from .sandbox import OpenSandboxSandbox, format_execution
|
|
|
|
if TYPE_CHECKING:
|
|
from opensandbox.config.connection_sync import ConnectionConfigSync
|
|
from opensandbox.models.execd import RunCommandOpts
|
|
from opensandbox.sync import SandboxSync
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_IMAGE = "python:3.11"
|
|
DEFAULT_READY_TIMEOUT = 30.0
|
|
DEFAULT_REQUEST_TIMEOUT = 30.0
|
|
DEFAULT_SANDBOX_TIMEOUT = 4 * 60 * 60
|
|
DEFAULT_COMMAND_TIMEOUT = 10 * 60
|
|
_BOOTSTRAP_TIMEOUT = 30.0
|
|
_BOOTSTRAP_COMMAND = "mkdir -p /mnt/user-data/workspace /mnt/user-data/uploads /mnt/user-data/outputs"
|
|
|
|
|
|
def _uses_insecure_remote_http(domain: Any, protocol: str) -> bool:
|
|
if not domain:
|
|
return False
|
|
value = str(domain)
|
|
try:
|
|
parsed = urlsplit(value if "://" in value else f"{protocol}://{value}")
|
|
except ValueError:
|
|
return False
|
|
if parsed.scheme.lower() != "http" or not parsed.hostname:
|
|
return False
|
|
if parsed.hostname.lower() == "localhost":
|
|
return False
|
|
try:
|
|
return not ipaddress.ip_address(parsed.hostname).is_loopback
|
|
except ValueError:
|
|
return True
|
|
|
|
|
|
def _import_sdk() -> tuple[type[SandboxSync], type[ConnectionConfigSync], type[RunCommandOpts]]:
|
|
"""Import the optional OpenSandbox sync SDK only when this provider is used."""
|
|
try:
|
|
from opensandbox.config.connection_sync import ConnectionConfigSync
|
|
from opensandbox.models.execd import RunCommandOpts
|
|
from opensandbox.sync import SandboxSync
|
|
except ImportError as exc: # pragma: no cover - depends on optional install state
|
|
raise ImportError("OpenSandboxProvider requires the optional 'opensandbox' dependency. Install it with: pip install 'deerflow-harness[opensandbox]' or pip install 'opensandbox>=0.1.15,<0.2.0'.") from exc
|
|
return SandboxSync, ConnectionConfigSync, RunCommandOpts
|
|
|
|
|
|
class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxProvider):
|
|
"""Create one OpenSandbox environment per effective user/thread scope."""
|
|
|
|
uses_thread_data_mounts = False
|
|
needs_upload_permission_adjustment = True
|
|
_idle_checker_thread_name = "opensandbox-idle-reaper"
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._sandboxes: dict[str, OpenSandboxSandbox] = {}
|
|
self._thread_sandboxes: dict[tuple[str, str], str] = {}
|
|
self._warm_pool: dict[str, tuple[OpenSandboxSandbox, float]] = {}
|
|
self._acquire_serializer: AcquireSerializer[str] = AcquireSerializer(thread_name_prefix="opensandbox-acquire-wait")
|
|
self._idle_checker_stop = threading.Event()
|
|
self._idle_checker_thread: threading.Thread | None = None
|
|
self._shutdown_called = False
|
|
self._sdk: tuple[type[SandboxSync], type[ConnectionConfigSync], type[RunCommandOpts]] | None = None
|
|
self._config = self._load_config()
|
|
atexit.register(self.shutdown)
|
|
self._start_idle_checker()
|
|
|
|
@staticmethod
|
|
def _positive_float(name: str, value: Any, default: float) -> float:
|
|
resolved = float(default if value is None else value)
|
|
if resolved <= 0:
|
|
raise ValueError(f"sandbox.{name} must be positive")
|
|
return resolved
|
|
|
|
def _load_config(self) -> dict[str, Any]:
|
|
sandbox_config = get_app_config().sandbox
|
|
|
|
def option(name: str, default: Any = None) -> Any:
|
|
return getattr(sandbox_config, name, default)
|
|
|
|
api_key = option("api_key")
|
|
domain = option("domain")
|
|
protocol = option("protocol") or "http"
|
|
effective_domain = domain or os.environ.get("OPEN_SANDBOX_DOMAIN")
|
|
if not (api_key or os.environ.get("OPEN_SANDBOX_API_KEY")) and not effective_domain:
|
|
logger.warning("OpenSandboxProvider: no api_key or domain configured (set sandbox.api_key/sandbox.domain in config.yaml or OPEN_SANDBOX_API_KEY/OPEN_SANDBOX_DOMAIN). The SDK will default to unauthenticated localhost:8080.")
|
|
if _uses_insecure_remote_http(effective_domain, protocol):
|
|
logger.warning("OpenSandboxProvider: remote OpenSandbox domain uses HTTP; use HTTPS to protect credentials and sandbox traffic.")
|
|
environment = dict(option("environment") or {})
|
|
_validate_extra_env(environment)
|
|
replicas = option("replicas")
|
|
idle_timeout = option("idle_timeout")
|
|
raw_sandbox_timeout = option("sandbox_timeout")
|
|
sandbox_timeout = float(DEFAULT_SANDBOX_TIMEOUT if raw_sandbox_timeout is None else raw_sandbox_timeout)
|
|
if sandbox_timeout < 0:
|
|
raise ValueError("sandbox.sandbox_timeout must be non-negative")
|
|
return {
|
|
"api_key": api_key,
|
|
"domain": domain,
|
|
"protocol": protocol,
|
|
"request_timeout": self._positive_float("request_timeout", option("request_timeout"), DEFAULT_REQUEST_TIMEOUT),
|
|
"use_server_proxy": bool(option("use_server_proxy", False)),
|
|
"image": option("image") or DEFAULT_IMAGE,
|
|
"ready_timeout": self._positive_float("ready_timeout", option("ready_timeout"), DEFAULT_READY_TIMEOUT),
|
|
"sandbox_timeout": None if sandbox_timeout == 0 else sandbox_timeout,
|
|
"command_timeout": self._positive_float("bash_command_timeout", option("bash_command_timeout"), DEFAULT_COMMAND_TIMEOUT),
|
|
"environment": self._resolve_env_vars(environment),
|
|
"replicas": replicas if replicas is not None else self.DEFAULT_REPLICAS,
|
|
"idle_timeout": idle_timeout if idle_timeout is not None else self.DEFAULT_IDLE_TIMEOUT,
|
|
}
|
|
|
|
@staticmethod
|
|
def _resolve_env_vars(env_config: dict[str, str]) -> dict[str, str]:
|
|
resolved: dict[str, str] = {}
|
|
for key, value in env_config.items():
|
|
if isinstance(value, str) and value.startswith("$"):
|
|
resolved[key] = os.environ.get(value[1:], "")
|
|
else:
|
|
resolved[key] = "" if value is None else str(value)
|
|
return resolved
|
|
|
|
def _get_sdk(self) -> tuple[type[SandboxSync], type[ConnectionConfigSync], type[RunCommandOpts]]:
|
|
with self._lock:
|
|
sdk = self._sdk
|
|
if sdk is not None:
|
|
return sdk
|
|
imported = _import_sdk()
|
|
with self._lock:
|
|
if self._sdk is None:
|
|
self._sdk = imported
|
|
return imported
|
|
return self._sdk
|
|
|
|
def _new_connection_config(self, connection_config_cls: type[ConnectionConfigSync]) -> ConnectionConfigSync:
|
|
# SandboxSync.create() derives an SDK-owned transport on a config copy,
|
|
# and destroy() closes that transport. A fresh base config per remote
|
|
# ensures no live sandbox can inherit another sandbox's transport.
|
|
return connection_config_cls(
|
|
api_key=self._config["api_key"],
|
|
domain=self._config["domain"],
|
|
protocol=self._config["protocol"],
|
|
request_timeout=timedelta(seconds=self._config["request_timeout"]),
|
|
use_server_proxy=self._config["use_server_proxy"],
|
|
)
|
|
|
|
@staticmethod
|
|
def _sandbox_id(thread_id: str, user_id: str) -> str:
|
|
return derive_sandbox_scope_token(user_id=user_id, thread_id=thread_id)
|
|
|
|
@staticmethod
|
|
def _thread_key(thread_id: str, user_id: str | None) -> tuple[str, str]:
|
|
return (user_id or "", thread_id)
|
|
|
|
def _start_idle_checker(self) -> None:
|
|
if self._config["idle_timeout"] <= 0:
|
|
return
|
|
super()._start_idle_checker()
|
|
|
|
def _active_count_locked(self) -> int:
|
|
return len(self._sandboxes)
|
|
|
|
def _destroy_warm_entry(self, sandbox_id: str, entry: OpenSandboxSandbox, *, reason: str) -> None:
|
|
self._destroy_quietly(entry, context=f"warm pool, reason={reason}")
|
|
|
|
@staticmethod
|
|
def _destroy_quietly(sandbox: OpenSandboxSandbox, *, context: str) -> None:
|
|
try:
|
|
sandbox.destroy()
|
|
except Exception as exc:
|
|
logger.warning("Error destroying OpenSandbox %s (%s): %s", sandbox.id, context, exc)
|
|
|
|
def _invalidate_sandbox(self, sandbox_id: str, reason: str) -> None:
|
|
with self._lock:
|
|
active = self._sandboxes.pop(sandbox_id, None)
|
|
warm_entry = self._warm_pool.pop(sandbox_id, None)
|
|
for key in [key for key, value in self._thread_sandboxes.items() if value == sandbox_id]:
|
|
self._thread_sandboxes.pop(key, None)
|
|
sandbox = active or (warm_entry[0] if warm_entry is not None else None)
|
|
if sandbox is None:
|
|
return
|
|
logger.warning("Invalidating OpenSandbox %s after terminal failure: %s", sandbox_id, reason)
|
|
self._destroy_quietly(sandbox, context="terminal failure")
|
|
|
|
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
|
|
with self._lock:
|
|
if self._shutdown_called:
|
|
raise RuntimeError("OpenSandboxProvider has been shut down")
|
|
if thread_id is None:
|
|
sandbox_id = str(uuid.uuid4())[:8]
|
|
sandbox = self._create_sandbox(sandbox_id, thread_id=None, user_id=user_id)
|
|
with self._lock:
|
|
if self._shutdown_called:
|
|
destroy_after_unlock = True
|
|
else:
|
|
self._sandboxes[sandbox_id] = sandbox
|
|
destroy_after_unlock = False
|
|
if destroy_after_unlock:
|
|
self._destroy_quietly(sandbox, context="created during shutdown")
|
|
raise RuntimeError("OpenSandboxProvider shut down during acquire")
|
|
return sandbox_id
|
|
|
|
key = self._thread_key(thread_id, user_id)
|
|
sandbox_id = self._sandbox_id(thread_id, user_id or "")
|
|
with self._acquire_serializer.hold(sandbox_id):
|
|
return self._acquire_scope_locked(key, sandbox_id, thread_id=thread_id, user_id=user_id)
|
|
|
|
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
|
|
"""Acquire without blocking the event loop.
|
|
|
|
The entire synchronous acquire (serializer wait + body) runs on the
|
|
serializer's dedicated executor — never the default executor. A
|
|
cancelled awaiter abandons the worker thread, which runs to
|
|
completion and releases the hold itself, so a retry serializes
|
|
behind it instead of overlapping the abandoned body.
|
|
"""
|
|
acquire = partial(self.acquire, thread_id, user_id=user_id)
|
|
return await self._acquire_serializer.run_on_executor(acquire)
|
|
|
|
def _acquire_scope_locked(self, key: tuple[str, str], sandbox_id: str, *, thread_id: str, user_id: str | None) -> str:
|
|
with self._lock:
|
|
existing = self._thread_sandboxes.get(key)
|
|
active = self._sandboxes.get(existing) if existing is not None else None
|
|
if existing is not None and active is not None:
|
|
try:
|
|
active.renew()
|
|
return existing
|
|
except Exception:
|
|
# A terminal renewal failure invokes _invalidate_sandbox,
|
|
# which removes and closes this exact client. Rebuild in the
|
|
# same acquire; transient errors leave the registry intact
|
|
# and must remain visible to the caller.
|
|
with self._lock:
|
|
invalidated = self._sandboxes.get(existing) is not active and existing not in self._warm_pool
|
|
shutting_down = self._shutdown_called
|
|
if not invalidated or not active.is_closed or shutting_down:
|
|
raise
|
|
logger.info("Rebuilding terminal OpenSandbox %s during acquire", existing)
|
|
reclaimed = self._reclaim_warm_pool(sandbox_id)
|
|
if reclaimed is not None:
|
|
with self._lock:
|
|
if self._shutdown_called:
|
|
raise RuntimeError("OpenSandboxProvider shut down during acquire")
|
|
if reclaimed in self._sandboxes:
|
|
self._thread_sandboxes[key] = reclaimed
|
|
return reclaimed
|
|
sandbox = self._create_sandbox(sandbox_id, thread_id=thread_id, user_id=user_id)
|
|
with self._lock:
|
|
if self._shutdown_called:
|
|
destroy_after_unlock = True
|
|
else:
|
|
self._sandboxes[sandbox_id] = sandbox
|
|
self._thread_sandboxes[key] = sandbox_id
|
|
destroy_after_unlock = False
|
|
if destroy_after_unlock:
|
|
self._destroy_quietly(sandbox, context="created during shutdown")
|
|
raise RuntimeError("OpenSandboxProvider shut down during acquire")
|
|
return sandbox_id
|
|
|
|
def _create_sandbox(self, sandbox_id: str, *, thread_id: str | None, user_id: str | None) -> OpenSandboxSandbox:
|
|
replicas, total = self._replica_count()
|
|
if total >= replicas:
|
|
evicted = self._evict_oldest_warm()
|
|
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
|
|
|
|
sandbox_cls, connection_config_cls, run_command_opts_cls = self._get_sdk()
|
|
connection_config = self._new_connection_config(connection_config_cls)
|
|
metadata = {"deer_flow_provider": "opensandbox"}
|
|
if thread_id is not None:
|
|
metadata["deer_flow_thread"] = thread_id
|
|
if user_id is not None:
|
|
metadata["deer_flow_user"] = user_id
|
|
remote = sandbox_cls.create(
|
|
self._config["image"],
|
|
timeout=None if self._config["sandbox_timeout"] is None else timedelta(seconds=self._config["sandbox_timeout"]),
|
|
ready_timeout=timedelta(seconds=self._config["ready_timeout"]),
|
|
env=self._config["environment"] or None,
|
|
metadata=metadata,
|
|
connection_config=connection_config,
|
|
)
|
|
try:
|
|
bootstrap = remote.commands.run(
|
|
_BOOTSTRAP_COMMAND,
|
|
opts=run_command_opts_cls(
|
|
timeout=timedelta(seconds=_BOOTSTRAP_TIMEOUT),
|
|
envs=self._config["environment"] or None,
|
|
),
|
|
)
|
|
exit_code = getattr(bootstrap, "exit_code", None)
|
|
if exit_code != 0:
|
|
detail = format_execution(bootstrap).strip() or ("no exit code" if exit_code is None else f"exit code {exit_code}")
|
|
raise RuntimeError(f"OpenSandbox bootstrap failed: {detail}")
|
|
except Exception:
|
|
try:
|
|
remote.destroy()
|
|
except Exception:
|
|
logger.warning("Failed to destroy OpenSandbox %s after bootstrap failure", remote.id, exc_info=True)
|
|
raise
|
|
|
|
return OpenSandboxSandbox(
|
|
sandbox_id,
|
|
remote,
|
|
run_command_opts_cls=run_command_opts_cls,
|
|
default_env=self._config["environment"],
|
|
sandbox_timeout=None if self._config["sandbox_timeout"] is None else timedelta(seconds=self._config["sandbox_timeout"]),
|
|
default_command_timeout=self._config["command_timeout"],
|
|
on_terminal_failure=self._invalidate_sandbox,
|
|
)
|
|
|
|
def _reclaim_warm_pool(self, sandbox_id: str) -> str | None:
|
|
with self._lock:
|
|
warm_entry = self._warm_pool.get(sandbox_id)
|
|
if warm_entry is None:
|
|
return None
|
|
sandbox = warm_entry[0]
|
|
if not sandbox.ping():
|
|
with self._lock:
|
|
removed = self._warm_pool.pop(sandbox_id, None)
|
|
if removed is not None:
|
|
self._destroy_warm_entry(sandbox_id, removed[0], reason="health_check_failed")
|
|
return None
|
|
with self._lock:
|
|
removed = self._warm_pool.pop(sandbox_id, None)
|
|
if removed is None:
|
|
return None
|
|
self._sandboxes[sandbox_id] = removed[0]
|
|
logger.info("Reclaimed warm OpenSandbox %s (remote=%s)", sandbox_id, sandbox.remote_id)
|
|
return sandbox_id
|
|
|
|
def get(self, sandbox_id: str) -> Sandbox | None:
|
|
with self._lock:
|
|
return self._sandboxes.get(sandbox_id)
|
|
|
|
def release(self, sandbox_id: str) -> None:
|
|
with self._lock:
|
|
sandbox = self._sandboxes.pop(sandbox_id, None)
|
|
for key in [key for key, value in self._thread_sandboxes.items() if value == sandbox_id]:
|
|
self._thread_sandboxes.pop(key, None)
|
|
if sandbox is None:
|
|
return
|
|
if self._shutdown_called:
|
|
destroy_after_unlock = True
|
|
else:
|
|
self._warm_pool[sandbox_id] = (sandbox, time.time())
|
|
destroy_after_unlock = False
|
|
if destroy_after_unlock:
|
|
self._destroy_quietly(sandbox, context="released during shutdown")
|
|
|
|
def reset(self) -> None:
|
|
"""Park active clients so the detached provider still owns their cleanup."""
|
|
with self._lock:
|
|
now = time.time()
|
|
for sandbox_id, sandbox in self._sandboxes.items():
|
|
self._warm_pool.setdefault(sandbox_id, (sandbox, now))
|
|
self._sandboxes.clear()
|
|
self._thread_sandboxes.clear()
|
|
self._acquire_serializer.close()
|
|
|
|
def shutdown(self) -> None:
|
|
with self._lock:
|
|
if self._shutdown_called:
|
|
return
|
|
self._shutdown_called = True
|
|
self._stop_idle_checker()
|
|
with self._lock:
|
|
sandboxes = list(self._sandboxes.values()) + [entry for entry, _ in self._warm_pool.values()]
|
|
self._sandboxes.clear()
|
|
self._warm_pool.clear()
|
|
self._thread_sandboxes.clear()
|
|
self._acquire_serializer.close()
|
|
for sandbox in sandboxes:
|
|
self._destroy_quietly(sandbox, context="shutdown")
|
|
|
|
|
|
__all__ = ["OpenSandboxProvider"]
|