mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
fix(sandbox): prevent AIO subagent session eviction (#5178)
* fix(sandbox): prevent AIO subagent session eviction * fix(sandbox): address PR 5178 review issues * fix(sandbox): handle transient session and metadata failures * fix(sandbox): fence capacity upgrades and validate reused limits * docs(sandbox): restore list indentation and trim guidance * fix(ci): stabilize Buzz persistence test and trim sandbox guidance --------- Co-authored-by: ranxi2001 <ranxi2001@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
cda56aa282
commit
d8d110c637
@ -927,6 +927,38 @@ inside the client. This prevents an inherited proxy from returning a misleading
|
||||
502 for a healthy local sandbox. Externally hosted sandbox FQDNs and public IPs
|
||||
continue to use the normal environment proxy configuration.
|
||||
|
||||
### AIO shell-session capacity
|
||||
|
||||
Each concurrently running native subagent uses one persistent AIO shell session.
|
||||
The semver AIO images from `1.9.3` through `1.11.0` default
|
||||
`MAX_SHELL_SESSIONS` to 10 and evict the oldest idle session when an eleventh is
|
||||
created. If `subagent_runtime.max_running` is greater than nine, DeerFlow sets
|
||||
the container limit to `max_running + 1`; the extra slot leaves room for the lead
|
||||
agent's shell. This applies to both locally created containers and provisioner
|
||||
Pods. Lower concurrency keeps the image's own default unchanged.
|
||||
|
||||
The separate `bash.exec` API uses its own `AIO_BASH_MAX_SESSIONS` pool rather
|
||||
than `MAX_SHELL_SESSIONS`. DeerFlow nevertheless creates and closes an explicit
|
||||
transient bash session around every env-bearing command, so request-scoped
|
||||
secrets and completed command sessions are not retained.
|
||||
|
||||
You may set `sandbox.environment.MAX_SHELL_SESSIONS` explicitly. It must be a
|
||||
positive integer at least as large as `subagent_runtime.max_running + 1`, or the
|
||||
provider fails at startup with the conflicting values. The setting is applied
|
||||
when a sandbox is created. Persisted local containers and provisioner Pods report
|
||||
their effective value; DeerFlow replaces one whose capacity is below the current
|
||||
requirement instead of reusing it. Reuse checks also apply when no explicit
|
||||
override is needed for new containers: a previously configured lower limit must
|
||||
still fit the current concurrency. The Gateway waits for existing ownership and
|
||||
the orphan recovery grace before replacing an incompatible sandbox. A create
|
||||
request that encounters a lower-capacity Pod returns HTTP 409 without deleting
|
||||
it; a later acquisition can discover and replace it through that same ownership
|
||||
check.
|
||||
|
||||
If a Service survives deletion of its old Pod, a later create repairs the
|
||||
missing Pod. A failed capacity read other than a Pod-not-found response remains
|
||||
an error and does not authorize replacement.
|
||||
|
||||
### Building a Custom AIO Sandbox Image
|
||||
|
||||
`AioSandboxProvider` talks to the sandbox container through the `agent-sandbox` SDK. The Dockerfile for the default `enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest` image is not part of this repository; DeerFlow treats that image as an upstream AIO sandbox runtime.
|
||||
|
||||
@ -197,6 +197,25 @@ class AioSandbox(Sandbox):
|
||||
exit_code = getattr(data, "exit_code", None) if data else None
|
||||
return output, exit_code
|
||||
|
||||
@staticmethod
|
||||
def _is_missing_shell_session_error(error: ApiError) -> bool:
|
||||
body = error.body
|
||||
if error.status_code != 404 or not isinstance(body, dict):
|
||||
return False
|
||||
message = body.get("message")
|
||||
return isinstance(message, str) and "session not found" in message.casefold()
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_bash_session_best_effort(client, session_id: str) -> None:
|
||||
try:
|
||||
client.bash.close_session(session_id)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(
|
||||
"Failed to release transient bash session %s: %s",
|
||||
session_id,
|
||||
cleanup_error,
|
||||
)
|
||||
|
||||
def _create_shell_session(self, client) -> str:
|
||||
session_id = str(uuid.uuid4())
|
||||
client.shell.create_session(id=session_id)
|
||||
@ -289,12 +308,24 @@ class AioSandbox(Sandbox):
|
||||
raise RuntimeError("sandbox client is closed")
|
||||
if scoped.session_id is None:
|
||||
scoped.session_id = self._create_shell_session(client)
|
||||
output, exit_code = self._exec_shell(
|
||||
client,
|
||||
command,
|
||||
session_id=scoped.session_id,
|
||||
)
|
||||
if output and _ERROR_OBSERVATION_SIGNATURE in output:
|
||||
try:
|
||||
output, exit_code = self._exec_shell(
|
||||
client,
|
||||
command,
|
||||
session_id=scoped.session_id,
|
||||
)
|
||||
except ApiError as error:
|
||||
if not self._is_missing_shell_session_error(error):
|
||||
raise
|
||||
logger.warning("Execution-scoped sandbox shell session is missing; recreating it once")
|
||||
scoped.session_id = None
|
||||
output, exit_code, scoped.session_id = self._rotate_and_retry_shell(
|
||||
client,
|
||||
command,
|
||||
corrupted_session_id=None,
|
||||
context="execution scope after missing session",
|
||||
)
|
||||
if scoped.session_id is not None and output and _ERROR_OBSERVATION_SIGNATURE in output:
|
||||
logger.warning("ErrorObservation detected in sandbox output for execution scope; rotating session")
|
||||
output, exit_code, scoped.session_id = self._rotate_and_retry_shell(
|
||||
client,
|
||||
@ -372,11 +403,11 @@ class AioSandbox(Sandbox):
|
||||
command: The command to execute.
|
||||
env: Optional per-call environment variables (request-scoped secrets,
|
||||
issue #3861). When provided, the command runs via the ``bash.exec``
|
||||
API (which supports per-command env) on a fresh auto-created session
|
||||
so the secrets are scoped to this single command and never persist;
|
||||
secret values travel in the structured ``env`` field, never in the
|
||||
command string. When ``None`` the legacy persistent-shell path runs
|
||||
unchanged.
|
||||
API (which supports per-command env) on a fresh explicitly released
|
||||
session, so the secrets are scoped to this single command and never
|
||||
persist; secret values travel in the structured ``env`` field, never
|
||||
in the command string. When ``None`` the legacy persistent-shell path
|
||||
runs unchanged.
|
||||
timeout: Optional per-call timeout. The current sandbox SDK does not
|
||||
expose a command-level timeout distinct from its client/request
|
||||
timeout, so DeerFlow keeps using the backend's default here.
|
||||
@ -403,13 +434,28 @@ class AioSandbox(Sandbox):
|
||||
# target it again. A failed replacement is cleaned up and
|
||||
# the next call starts another explicit session.
|
||||
self._recovery_session_id = self._create_shell_session(client)
|
||||
output, exit_code = self._exec_shell(
|
||||
client,
|
||||
command,
|
||||
session_id=self._recovery_session_id,
|
||||
)
|
||||
recovered_missing_session = False
|
||||
try:
|
||||
output, exit_code = self._exec_shell(
|
||||
client,
|
||||
command,
|
||||
session_id=self._recovery_session_id,
|
||||
)
|
||||
except ApiError as error:
|
||||
if not self._is_missing_shell_session_error(error):
|
||||
raise
|
||||
logger.warning("Default sandbox shell session is missing; recreating it once")
|
||||
self._default_shell_corrupted = True
|
||||
self._recovery_session_id = None
|
||||
recovered_missing_session = True
|
||||
output, exit_code, self._recovery_session_id = self._rotate_and_retry_shell(
|
||||
client,
|
||||
command,
|
||||
corrupted_session_id=None,
|
||||
context="default shell after missing session",
|
||||
)
|
||||
|
||||
if output and _ERROR_OBSERVATION_SIGNATURE in output:
|
||||
if not recovered_missing_session and output and _ERROR_OBSERVATION_SIGNATURE in output:
|
||||
self._default_shell_corrupted = True
|
||||
logger.warning("ErrorObservation detected in sandbox output, retrying on a fresh session")
|
||||
output, exit_code, self._recovery_session_id = self._rotate_and_retry_shell(
|
||||
@ -429,10 +475,11 @@ class AioSandbox(Sandbox):
|
||||
|
||||
The persistent-shell ``shell.exec_command`` API has no env parameter, so
|
||||
injected commands use the ``bash.exec`` API which accepts per-command env.
|
||||
Each call lets the sandbox auto-create a fresh session (no ``session_id``),
|
||||
so injected request-scoped secrets are scoped to this command and never
|
||||
persist across calls. Secret values travel in the structured ``env`` field,
|
||||
never in the command string.
|
||||
Each call creates an explicit transient session and closes it after the
|
||||
command, so injected request-scoped secrets are scoped to this command,
|
||||
never persist across calls, and do not consume the server's session
|
||||
capacity after completion. Secret values travel in the structured
|
||||
``env`` field, never in the command string.
|
||||
|
||||
Trade-off of the fresh-session choice: consecutive env-bearing bash calls
|
||||
within the same skill do not share session state (cwd, sourced venv,
|
||||
@ -463,36 +510,52 @@ class AioSandbox(Sandbox):
|
||||
return output
|
||||
|
||||
def _run_bash_exec(self, command: str, env: dict[str, str]) -> str:
|
||||
"""Single bash.exec invocation with injected env (one fresh session)."""
|
||||
"""Single bash.exec invocation in an explicitly released fresh session."""
|
||||
with self._lock:
|
||||
try:
|
||||
result = self._client.bash.exec(
|
||||
command=command,
|
||||
env=env,
|
||||
hard_timeout=self._DEFAULT_HARD_TIMEOUT,
|
||||
)
|
||||
data = result.data if result else None
|
||||
stdout = (data.stdout or "") if data else ""
|
||||
stderr = (data.stderr or "") if data else ""
|
||||
exit_code = getattr(data, "exit_code", None) if data else None
|
||||
output = stdout
|
||||
if stderr:
|
||||
output += f"\nStd Error:\n{stderr}" if output else stderr
|
||||
if exit_code not in (0, None):
|
||||
# Mirror LocalSandbox: keep the actual shell status in the
|
||||
# output text (acceptance-checklist evidence).
|
||||
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
|
||||
return output if output else "(no output)"
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
self._bash_exec_unsupported = True
|
||||
logger.error("Sandbox %s does not support bash.exec (/v1/bash/exec returned 404); env-bearing commands are unavailable until the sandbox image is upgraded to all-in-one-sandbox >= 1.9.3", self.id)
|
||||
return _BASH_EXEC_UNSUPPORTED_ERROR
|
||||
logger.error(f"Failed to execute command with injected env in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute command with injected env in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
for attempt in range(2):
|
||||
session_id = str(uuid.uuid4())
|
||||
session_created = False
|
||||
try:
|
||||
self._client.bash.create_session(session_id=session_id)
|
||||
session_created = True
|
||||
result = self._client.bash.exec(
|
||||
command=command,
|
||||
session_id=session_id,
|
||||
env=env,
|
||||
hard_timeout=self._DEFAULT_HARD_TIMEOUT,
|
||||
)
|
||||
data = result.data if result else None
|
||||
stdout = (data.stdout or "") if data else ""
|
||||
stderr = (data.stderr or "") if data else ""
|
||||
exit_code = getattr(data, "exit_code", None) if data else None
|
||||
output = stdout
|
||||
if stderr:
|
||||
output += f"\nStd Error:\n{stderr}" if output else stderr
|
||||
if exit_code not in (0, None):
|
||||
# Mirror LocalSandbox: keep the actual shell status in the
|
||||
# output text (acceptance-checklist evidence).
|
||||
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
|
||||
return output if output else "(no output)"
|
||||
except ApiError as e:
|
||||
if self._is_missing_shell_session_error(e):
|
||||
if attempt == 0:
|
||||
logger.warning("Transient bash.exec session disappeared; retrying once")
|
||||
continue
|
||||
logger.error("Failed to execute command with injected env: bash.exec session disappeared after retry")
|
||||
return "Error: bash.exec session disappeared after retry"
|
||||
if e.status_code == 404:
|
||||
self._bash_exec_unsupported = True
|
||||
logger.error("Sandbox %s does not support bash.exec (/v1/bash/exec returned 404); env-bearing commands are unavailable until the sandbox image is upgraded to all-in-one-sandbox >= 1.9.3", self.id)
|
||||
return _BASH_EXEC_UNSUPPORTED_ERROR
|
||||
logger.error(f"Failed to execute command with injected env in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute command with injected env in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
finally:
|
||||
if session_created:
|
||||
self._cleanup_bash_session_best_effort(self._client, session_id)
|
||||
return "Error: bash.exec session disappeared after retry"
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
|
||||
@ -70,6 +70,11 @@ DEFAULT_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in
|
||||
DEFAULT_PORT = 8080
|
||||
DEFAULT_CONTAINER_PREFIX = "deer-flow-sandbox"
|
||||
IDLE_CHECK_INTERVAL = _SHARED_IDLE_CHECK_INTERVAL
|
||||
# The supported semver AIO images currently default to ten shell sessions.
|
||||
# Leave lower-concurrency deployments on the image default; only override it
|
||||
# when DeerFlow's configured execution capacity cannot fit.
|
||||
_AIO_DEFAULT_MAX_SHELL_SESSIONS = 10
|
||||
_SHELL_SESSION_HEADROOM = 1
|
||||
|
||||
|
||||
class SandboxBeingDestroyedError(RuntimeError):
|
||||
@ -260,7 +265,12 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
||||
raise RuntimeError("sandbox.network restricted modes are currently supported only by the local Docker AIO backend")
|
||||
logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}")
|
||||
api_key = self._config.get("provisioner_api_key", "")
|
||||
return RemoteSandboxBackend(provisioner_url=provisioner_url, api_key=api_key)
|
||||
return RemoteSandboxBackend(
|
||||
provisioner_url=provisioner_url,
|
||||
api_key=api_key,
|
||||
max_shell_sessions=self._config.get("max_shell_sessions"),
|
||||
required_shell_sessions=self._config.get("required_shell_sessions", 0),
|
||||
)
|
||||
|
||||
logger.info("Using local container sandbox backend")
|
||||
return LocalContainerBackend(
|
||||
@ -270,6 +280,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
||||
config_mounts=self._config["mounts"],
|
||||
environment=self._config["environment"],
|
||||
network_config=self._config["network"],
|
||||
required_shell_sessions=self._config.get("required_shell_sessions", 0),
|
||||
)
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────────
|
||||
@ -289,6 +300,22 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
||||
if not isinstance(configured_skills_path, str):
|
||||
configured_skills_path = DEFAULT_SKILLS_CONTAINER_PATH
|
||||
|
||||
environment = self._resolve_env_vars(sandbox_config.environment or {})
|
||||
max_running_subagents = int(getattr(getattr(config, "subagent_runtime", None), "max_running", 3))
|
||||
required_shell_sessions = max_running_subagents + _SHELL_SESSION_HEADROOM
|
||||
configured_shell_sessions = environment.get("MAX_SHELL_SESSIONS")
|
||||
if configured_shell_sessions is None:
|
||||
max_shell_sessions = required_shell_sessions if required_shell_sessions > _AIO_DEFAULT_MAX_SHELL_SESSIONS else None
|
||||
if max_shell_sessions is not None:
|
||||
environment["MAX_SHELL_SESSIONS"] = str(max_shell_sessions)
|
||||
else:
|
||||
try:
|
||||
max_shell_sessions = int(configured_shell_sessions)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("sandbox.environment.MAX_SHELL_SESSIONS must be a positive integer") from exc
|
||||
if max_shell_sessions < required_shell_sessions:
|
||||
raise ValueError(f"sandbox.environment.MAX_SHELL_SESSIONS must be at least subagent_runtime.max_running + {_SHELL_SESSION_HEADROOM} ({required_shell_sessions} for the current configuration)")
|
||||
|
||||
return {
|
||||
"image": sandbox_config.image or DEFAULT_IMAGE,
|
||||
"port": sandbox_config.port or DEFAULT_PORT,
|
||||
@ -297,7 +324,9 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
||||
"replicas": replicas if replicas is not None else DEFAULT_REPLICAS,
|
||||
"mounts": sandbox_config.mounts or [],
|
||||
"thread_data_mounts": getattr(sandbox_config, "thread_data_mounts", None),
|
||||
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
|
||||
"environment": environment,
|
||||
"max_shell_sessions": max_shell_sessions,
|
||||
"required_shell_sessions": required_shell_sessions,
|
||||
"network": sandbox_config.network.model_dump(),
|
||||
"ownership": getattr(sandbox_config, "ownership", None),
|
||||
# A redis stream bridge means the deployment is multi-instance, which
|
||||
|
||||
@ -31,6 +31,8 @@ from .sandbox_info import SandboxInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AIO_DEFAULT_MAX_SHELL_SESSIONS = 10
|
||||
|
||||
|
||||
class _ExistingRestrictedSandbox(RuntimeError):
|
||||
def __init__(self, info: SandboxInfo):
|
||||
@ -46,6 +48,7 @@ class _ContainerInspection:
|
||||
image: str
|
||||
networks: frozenset[str]
|
||||
relay_token: str | None = None
|
||||
max_shell_sessions: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -513,6 +516,7 @@ class LocalContainerBackend(SandboxBackend):
|
||||
config_mounts: list,
|
||||
environment: dict[str, str],
|
||||
network_config: dict[str, object] | None = None,
|
||||
required_shell_sessions: int = 0,
|
||||
):
|
||||
"""Initialize the local container backend.
|
||||
|
||||
@ -522,12 +526,14 @@ class LocalContainerBackend(SandboxBackend):
|
||||
container_prefix: Prefix for container names (e.g., "deer-flow-sandbox").
|
||||
config_mounts: Volume mount configurations from config (list of VolumeMountConfig).
|
||||
environment: Environment variables to inject into containers.
|
||||
required_shell_sessions: Minimum usable capacity, independent of image environment overrides.
|
||||
"""
|
||||
self._image = image
|
||||
self._base_port = base_port
|
||||
self._container_prefix = container_prefix
|
||||
self._config_mounts = config_mounts
|
||||
self._environment = environment
|
||||
self._required_shell_sessions = required_shell_sessions
|
||||
self._network_config = network_config or {"mode": "open"}
|
||||
self._network_mode = str(self._network_config.get("mode", "open"))
|
||||
self._allow_synthetic_dns = False
|
||||
@ -572,6 +578,18 @@ class LocalContainerBackend(SandboxBackend):
|
||||
"deerflow.network_mode": self._network_mode,
|
||||
}
|
||||
|
||||
def _has_compatible_shell_capacity(self, inspection: _ContainerInspection) -> bool:
|
||||
"""Check both the runtime minimum and any explicit environment override."""
|
||||
configured = self._environment.get("MAX_SHELL_SESSIONS")
|
||||
required = self._required_shell_sessions
|
||||
try:
|
||||
if configured is not None:
|
||||
required = max(required, int(configured))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
actual = inspection.max_shell_sessions if inspection.max_shell_sessions is not None else _AIO_DEFAULT_MAX_SHELL_SESSIONS
|
||||
return actual >= required
|
||||
|
||||
def _network_policy_digest(self) -> str:
|
||||
allow_domains = self._network_config.get("allow_domains", [])
|
||||
canonical_domains = sorted({value for value in allow_domains if isinstance(value, str)}) if isinstance(allow_domains, list) else []
|
||||
@ -1223,6 +1241,14 @@ class LocalContainerBackend(SandboxBackend):
|
||||
created_at=created_at,
|
||||
requires_replacement=True,
|
||||
)
|
||||
if not self._has_compatible_shell_capacity(sandbox_inspection):
|
||||
return SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url="",
|
||||
container_name=container_name,
|
||||
created_at=created_at,
|
||||
requires_replacement=True,
|
||||
)
|
||||
|
||||
if self._network_mode != "open":
|
||||
proxy_name, _ = self._resource_names(sandbox_id)
|
||||
@ -1375,7 +1401,7 @@ class LocalContainerBackend(SandboxBackend):
|
||||
continue
|
||||
created_at, host_port = data.created_at, data.host_port
|
||||
request_headers: dict[str, str] = {}
|
||||
requires_replacement = persisted_mode != self._network_mode
|
||||
requires_replacement = persisted_mode != self._network_mode or not self._has_compatible_shell_capacity(data)
|
||||
if not requires_replacement and self._network_mode != "open":
|
||||
proxy_name, _ = self._resource_names(sandbox_id)
|
||||
proxy_data = inspections.get(proxy_name)
|
||||
@ -1555,6 +1581,17 @@ class LocalContainerBackend(SandboxBackend):
|
||||
host_port = _extract_host_port(entry, 8080)
|
||||
config = entry.get("Config") or {}
|
||||
network_settings = entry.get("NetworkSettings") or {}
|
||||
max_shell_sessions: int | None = None
|
||||
configured_shell_sessions = _extract_container_environment(config, "MAX_SHELL_SESSIONS")
|
||||
if configured_shell_sessions is not None:
|
||||
try:
|
||||
parsed_shell_sessions = int(configured_shell_sessions)
|
||||
if parsed_shell_sessions > 0:
|
||||
max_shell_sessions = parsed_shell_sessions
|
||||
else:
|
||||
max_shell_sessions = 0
|
||||
except ValueError:
|
||||
max_shell_sessions = 0
|
||||
out[name] = _ContainerInspection(
|
||||
created_at=created_at,
|
||||
host_port=host_port,
|
||||
@ -1562,6 +1599,7 @@ class LocalContainerBackend(SandboxBackend):
|
||||
image=str(config.get("Image") or ""),
|
||||
networks=frozenset(str(value) for value in (network_settings.get("Networks") or {})),
|
||||
relay_token=_extract_container_environment(config, RELAY_TOKEN_ENV),
|
||||
max_shell_sessions=max_shell_sessions,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ _RESERVED_SANDBOX_MOUNT_PATHS = (
|
||||
_LARK_CLI_RUNTIME_CONTAINER_PATH = "/mnt/integrations/lark-cli/runtime"
|
||||
_LARK_CLI_CONFIG_CONTAINER_PATH = "/mnt/integrations/lark-cli/config"
|
||||
_LARK_CLI_DATA_CONTAINER_PATH = "/mnt/integrations/lark-cli/data"
|
||||
_AIO_DEFAULT_MAX_SHELL_SESSIONS = 10
|
||||
|
||||
|
||||
def _normalize_skills_container_path(container_path: str) -> str:
|
||||
@ -141,7 +142,14 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
provisioner_api_key: $PROVISIONER_API_KEY
|
||||
"""
|
||||
|
||||
def __init__(self, provisioner_url: str, api_key: str = ""):
|
||||
def __init__(
|
||||
self,
|
||||
provisioner_url: str,
|
||||
api_key: str = "",
|
||||
max_shell_sessions: int | None = None,
|
||||
*,
|
||||
required_shell_sessions: int = 0,
|
||||
):
|
||||
"""Initialize with the provisioner service URL and optional API key.
|
||||
|
||||
Args:
|
||||
@ -149,9 +157,14 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
(e.g., ``http://provisioner:8002``).
|
||||
api_key: Value sent as ``X-API-Key`` header on every request.
|
||||
Leave empty to send no authentication header.
|
||||
max_shell_sessions: Optional AIO shell-session capacity forwarded
|
||||
to each provisioned sandbox Pod.
|
||||
required_shell_sessions: Minimum usable capacity, even when new Pods use the image default.
|
||||
"""
|
||||
self._provisioner_url = provisioner_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._max_shell_sessions = max_shell_sessions
|
||||
self._required_shell_sessions = max(required_shell_sessions, max_shell_sessions or 0)
|
||||
|
||||
@property
|
||||
def provisioner_url(self) -> str:
|
||||
@ -160,6 +173,15 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {"X-API-Key": self._api_key} if self._api_key else {}
|
||||
|
||||
def _requires_shell_capacity_replacement(self, payload: dict[str, object]) -> bool:
|
||||
if self._required_shell_sessions == 0:
|
||||
return False
|
||||
reported = payload.get("max_shell_sessions", _AIO_DEFAULT_MAX_SHELL_SESSIONS)
|
||||
try:
|
||||
return int(reported) < self._required_shell_sessions
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
|
||||
# ── SandboxBackend interface ──────────────────────────────────────────
|
||||
|
||||
def create(
|
||||
@ -242,7 +264,13 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
sandbox_id = sandbox.get("sandbox_id")
|
||||
sandbox_url = sandbox.get("sandbox_url")
|
||||
if isinstance(sandbox_id, str) and sandbox_id and isinstance(sandbox_url, str) and sandbox_url:
|
||||
infos.append(SandboxInfo(sandbox_id=sandbox_id, sandbox_url=sandbox_url))
|
||||
infos.append(
|
||||
SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=sandbox_url,
|
||||
requires_replacement=self._requires_shell_capacity_replacement(sandbox),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Provisioner list_running: %d sandbox(es) found", len(infos))
|
||||
return infos
|
||||
@ -274,6 +302,8 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
"provision_lark_cli_runtime": provision_lark_cli_runtime,
|
||||
"provision_lark_cli_broker": provision_lark_cli_broker,
|
||||
}
|
||||
if self._max_shell_sessions is not None:
|
||||
payload["max_shell_sessions"] = self._max_shell_sessions
|
||||
provisioner_extra_mounts = _provisioner_extra_mounts_payload(
|
||||
extra_mounts,
|
||||
skills_container_path=normalized_skills_container_path,
|
||||
@ -291,6 +321,10 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if self._max_shell_sessions is not None and "max_shell_sessions" not in data:
|
||||
raise RuntimeError("Provisioner did not report max_shell_sessions; Gateway/provisioner version skew prevents shell-capacity validation")
|
||||
if self._requires_shell_capacity_replacement(data):
|
||||
raise RuntimeError(f"Provisioner returned sandbox {sandbox_id} with insufficient shell-session capacity")
|
||||
logger.info(f"Provisioner created sandbox {sandbox_id}: sandbox_url={data['sandbox_url']}")
|
||||
return SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
@ -349,6 +383,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
return SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=data["sandbox_url"],
|
||||
requires_replacement=self._requires_shell_capacity_replacement(data),
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.debug(f"Provisioner discover failed for {sandbox_id}: {exc}")
|
||||
|
||||
@ -3,13 +3,13 @@
|
||||
**Interface**: `Sandbox`: `execute_command(command, env=None)`, additive `execute_command_in_scope(..., scope_id=...)` / `release_command_scope(scope_id)`, `read_file`, `write_file`, `list_dir`, `glob`, `grep`. Scoped hooks default to pass-through without server-side sessions, preserving third-party subclasses. `grep` accepts a text file or directory tree. Per-call `env` injects secrets: `LocalSandbox` merges into the subprocess environment; `AioSandbox` uses fresh `bash.exec(env=...)` sessions. `list_dir`: missing path → `FileNotFoundError`; command/client failure → `OSError`, never `[]` (`ls_tool`: `(empty)`). Remote `glob`/`grep` share it via `sandbox/remote_search.py`: missing root → `FileNotFoundError`, failed search → `OSError`; only a genuine no-match returns `[]`. The parser takes the command's `limit` and reports `truncated` when output passed it, which providers return after Python-side filtering; tools call an empty truncated result incomplete. Remote `grep(glob=...)` scopes like `glob()` (root-relative `path_matches`), never by basename alone. Remotes use `sandbox/remote_list_dir.py`: capture `find`'s status, not `| head`'s (`sh -lc` lacks `pipefail`); missing binary → `OSError`; truncation SIGPIPE → success.
|
||||
**Provider Pattern**: `SandboxProvider` exposes `acquire`, `acquire_async`, `get`, `release`. Async agent/tool paths use async hooks to keep Docker creation, discovery, cross-process locking, readiness polling, and release off-loop. Set `supports_agent_skill_isolation=True` only when the whole tool surface enforces explicit lead Agent policy: bind mounts use prepared thread roots; upload providers implement `sync_agent_skills`. Host-backed providers report false if an enabled shell bypasses path mappings. Under explicit policy, middleware rejects unsupported providers before acquire.
|
||||
**Shared components** (RFC #4741): remote IDs use `derive_sandbox_scope_token` (`sandbox/identity.py`); preserve its keyword-only SHA-256/16-hex contract to avoid orphaning containers. `AcquireSerializer` (`sandbox/acquire_serialization.py`) serializes selected acquire/release transitions with a bounded, refcounted per-key `threading.Lock` table and dedicated bounded executor (no event-loop/default-executor blocking). Workers own cancellation cleanup without waiting for cancelled tasks to resume; provider `shutdown()`/`reset()` calls idempotent `close()`. Keys: AIO `(user_id, thread_id)`, E2B `(user_id, thread_id, skills_root)`, BoxLite/Tenki/OpenSandbox derived id. Random-UUID `thread_id=None` acquires bypass serialization.
|
||||
**Execution leases** (`sandbox/lease.py`, #5128): cross-instance ownership decides which Gateway may reap a container; process-local `SandboxLeaseManager` tracks concurrent lead, subagent, Gateway-request, and channel-upload users of one client. Runs get ephemeral owners, persisted sandboxes are retained idempotently, and the last holder performs any pending `SandboxProvider.release`. Outer lifecycle fences repeat idempotent release after their complete graph/tool/request batch drains; per-tool terminal `Command` wrappers never release because sibling handlers may still run. Fork-restored children and upload syncs use non-releasing holders: they fence the client and own scope cleanup without themselves requesting a park; an earlier normal-owner request waits for them, and a missing fork client is replaced by a normal owner. Persisted lookup plus retention is serialized per `(user_id, thread_id)`; stale bindings fall through to acquire, and a post-acquire lookup miss rolls back before raising. Provider I/O does not hold the metadata lock. Repeated cancellation cannot interrupt acquire/rollback/release reconciliation or let a `to_thread` sandbox operation outlive its enclosing execution/request holder; failures are logged without replacing the original cancellation. Lease/scope context IDs are server-owned: Gateway and worker scrub caller values; only the internal subagent path assigns a task ID. Managers are registered by provider object identity, not hash/equality, so unhashable custom providers remain valid. Subagent owners also serve as `sandbox_command_scope_id`: AIO gives each scope one ordered persistent shell session, replaces it after `ErrorObservation`, and cleans it on lease release. Registry identity is revalidated after every scope-lock wait, preventing queued commands from resurrecting released sessions. Env-bearing commands use fresh `bash.exec` sessions so secrets do not persist.
|
||||
**Execution leases** (`sandbox/lease.py`, #5128): cross-instance ownership decides which Gateway may reap a container; process-local `SandboxLeaseManager` tracks concurrent lead, subagent, Gateway-request, and channel-upload users of one client. Runs get ephemeral owners, persisted sandboxes are retained idempotently, and the last holder performs any pending `SandboxProvider.release`. Outer lifecycle fences repeat idempotent release after their complete graph/tool/request batch drains; per-tool terminal `Command` wrappers never release because sibling handlers may still run. Fork-restored children and upload syncs use non-releasing holders: they fence the client and own scope cleanup without themselves requesting a park; an earlier normal-owner request waits for them, and a missing fork client is replaced by a normal owner. Persisted lookup plus retention is serialized per `(user_id, thread_id)`; stale bindings fall through to acquire, and a post-acquire lookup miss rolls back before raising. Provider I/O does not hold the metadata lock. Repeated cancellation cannot interrupt acquire/rollback/release reconciliation or let a `to_thread` sandbox operation outlive its enclosing execution/request holder; failures are logged without replacing the original cancellation. Lease/scope context IDs are server-owned: Gateway and worker scrub caller values; only the internal subagent path assigns a task ID. Managers are registered by provider object identity, not hash/equality, so unhashable custom providers remain valid. Subagent owners also serve as `sandbox_command_scope_id`: AIO gives each scope one ordered persistent shell session, replaces it after `ErrorObservation` or a missing-session 404, and cleans it on lease release. Registry identity is revalidated after every scope-lock wait, preventing queued commands from resurrecting released sessions. Env-bearing commands use fresh `bash.exec` sessions so secrets do not persist.
|
||||
**Authorization gate** (`sandbox:execute`, RFC #4063 Phase 3): every sandbox-backed tool call passes through the gate in `deerflow/authz/sandbox_authz.py` - a binary `authorize(principal, "sandbox", "execute", target="*")` check before either reusing a persisted sandbox id or calling `provider.acquire`. Rechecking reuse is required because authorization config and user roles can change while the sandbox remains cached. Sync tool invocations call `authorize_sandbox_execution`; async tool invocations await `authorize_sandbox_execution_async` exactly once. A task-local `ContextVar` scopes that single decision across the complete composed tool invocation, including `ReadBeforeWriteMiddleware`'s pre-write inspection, tool body, and post-read mark; the value is copied into `asyncio.to_thread` workers. Authorization denial is converted to the normal error `ToolMessage` at the composed middleware boundary and is explicitly excluded from the gate's generic fail-open handlers. Async config loading and provider class discovery/import are offloaded before `aauthorize()` so reused sandbox calls do not hash config files or import custom modules on the event loop; provider construction remains on the running event loop because async providers may initialize loop-affine clients. The gate lives at the single tool initialization entry point (`ensure_sandbox_initialized` / `ensure_sandbox_initialized_async` in `tools.py`), while `SandboxMiddleware.before_agent` / `abefore_agent` apply the matching sync/async check to eager acquisition. Deny raises `SandboxAuthorizationError` (`sandbox/exceptions.py`), which propagates out of ordinary tool execution as a friendly error `ToolMessage` ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (authorization calls and provider resolution) follow `authorization.fail_closed` / `fail_open`; no readable `config.yaml` or `authorization.enabled: false` makes the gate a no-op (`safe_app_config` tolerates missing config). Gateway upload/artifact sync calls `try_acquire_sandbox_for_request` (`app/gateway/authz.py`), which gates, returns a request lease, and skips sync on deny while preserving the primary operation. Callers release after their last sandbox operation; artifacts request normal parking, uploads do not. Tests: `tests/test_sandbox_authorization.py` and `tests/blocking_io/test_sandbox_authorization.py`.
|
||||
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
|
||||
**Implementations**:
|
||||
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-user/thread `LocalSandbox` (id `local:{user_id}:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Shared runs use category mappings; a policy-scoped run replaces them with one `/mnt/skills` root mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled host `bash` subprocess can use canonical paths without `PathMapping`, so `supports_agent_skill_isolation` is dynamic and explicit Agent policies fail closed while host bash is enabled. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeated `realpath()` walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. The shared `path_patterns.py` tail stops at `:`, so `$PATH`-style lists mask every entry; a mount symlink resolving outside all mounts keeps its mount path. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths.
|
||||
PowerShell fallback pairs guarded console UTF-8 setters with explicit UTF-8 pipe decoding; cmd/MSYS retain locale decoding. Encoding regressions include real-pipe probes and Windows-only PowerShell roundtrips with and without a console.
|
||||
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). Text appends use the AIO file API's native append mode rather than a client-side read-modify-write, so a failed pre-read cannot turn an append into an overwrite. `reset()` closes the per-instance acquire serializer so replacing the singleton cannot retain its executor workers; full remote sandbox teardown remains `shutdown()`. `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner backends=False), while the optional `sandbox.thread_data_mounts` boolean takes precedence for deployments that guarantee the Gateway and sandbox share the same thread user-data directories. Setting it `true` skips upload-time sandbox acquire/sync; a false positive leaves uploads unavailable to the sandbox. An explicit Agent policy uses four thread projection category mounts and a distinct deterministic sandbox identity, preventing reuse of an older container created with shared mounts. `skills.container_path` is a provider-startup snapshot shared by mount construction, sandbox identity, the remote Gateway request, and provisioner validation; custom roots are identity-scoped so a container or Pod created for one destination cannot be reused after the root changes. The Gateway and provisioner independently require one canonical absolute root that does not overlap reserved platform mounts, and both derive the four category allowlist entries from that root. The provisioner accepts all four category overrides; when all are present it suppresses the default hostPath or skills-PVC mount. With `USERDATA_PVC_NAME`, the thread projection categories use subpaths on that shared data PVC. Readiness probes and `agent_sandbox` clients classify loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames as direct control-plane destinations and set `trust_env=False`; external FQDNs and public IPs retain environment proxy support.
|
||||
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker isolation. Reuse enforces minimum persisted shell capacity even without image-env overrides. Upgrades require ownership-fenced teardown; provisioner create returns 409 for undersized Pods without deleting them. Acquire/reuse checks active-cache and warm-pool entries with the backend, dropping definitively dead containers from all in-process maps before rediscovery/creation. Health-check failures mean unknown, not dead; local discovery skips unverifiable containers and tries creation. Acquire/reclaim publishes ownership; `_renew_owned_leases` refreshes it on a dedicated thread. Text appends use native AIO append, never read-modify-write that could overwrite after a failed read. `reset()` closes the acquire serializer and its workers; `shutdown()` performs full remote teardown. `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner=False); `sandbox.thread_data_mounts` overrides this for deployments sharing thread user-data directories. `true` skips upload acquire/sync; a false positive makes uploads unavailable. Explicit Agent policies use four thread projection category mounts and a distinct deterministic identity, excluding older shared-mount containers. The startup snapshot of `skills.container_path` drives mounts, identity, remote requests, and provisioner validation; root changes prevent container/Pod reuse. Gateway and provisioner independently require a canonical absolute root outside reserved platform mounts and derive four category allowlist entries from it. All four category overrides suppress the provisioner's default hostPath/skills-PVC mount; with `USERDATA_PVC_NAME`, categories use subpaths on that shared PVC. Readiness probes and `agent_sandbox` clients set `trust_env=False` for direct control-plane destinations (loopback/private IPs, single-label cluster hosts, Docker/Podman internal hostnames); external FQDNs and public IPs retain environment proxies.
|
||||
- `E2BSandboxProvider` (`packages/harness/deerflow/community/e2b_sandbox/`) provides E2B remote isolation.
|
||||
New unrestricted sandboxes receive a one-shot upload from the enabled-only
|
||||
public, custom, legacy, and managed integration projections. For a thread
|
||||
|
||||
@ -151,7 +151,8 @@ async def test_abandoned_relay_records_are_drained_by_retried_stop_or_restart(tm
|
||||
await abandoned_relay
|
||||
assert await seen_events.aseen(_CHANNEL_ID, "late-event")
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
assert seen_events._flush_handle is None
|
||||
assert seen_events._flush_task is None
|
||||
stopped_view = BuzzSeenEventStore(path)
|
||||
assert not await stopped_view.aseen(_CHANNEL_ID, "late-event")
|
||||
|
||||
@ -160,15 +161,28 @@ async def test_abandoned_relay_records_are_drained_by_retried_stop_or_restart(tm
|
||||
assert await retried_stop_view.aseen(_CHANNEL_ID, "late-event")
|
||||
|
||||
await seen_events.arecord(_CHANNEL_ID, "restart-event")
|
||||
await asyncio.sleep(0.05)
|
||||
assert seen_events._flush_handle is None
|
||||
assert seen_events._flush_task is None
|
||||
still_stopped_view = BuzzSeenEventStore(path)
|
||||
assert not await still_stopped_view.aseen(_CHANNEL_ID, "restart-event")
|
||||
|
||||
flush_completed = asyncio.Event()
|
||||
original_flush_once = seen_events._flush_once
|
||||
|
||||
async def tracked_flush_once() -> bool:
|
||||
saved = await original_flush_once()
|
||||
flush_completed.set()
|
||||
return saved
|
||||
|
||||
monkeypatch.setattr(seen_events, "_flush_once", tracked_flush_once)
|
||||
monkeypatch.setattr(buzz_nostr, "parse_private_key", lambda _value: buzz_nostr.NostrKeys(secret=b"", pubkey_hex=_BOT_PUBLIC))
|
||||
channel._spawn_connection = lambda: None
|
||||
await channel.start()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
restarted_view = BuzzSeenEventStore(path)
|
||||
assert await restarted_view.aseen(_CHANNEL_ID, "restart-event")
|
||||
await channel.stop()
|
||||
try:
|
||||
# Wait for the automatically resumed write, not a fixed disk/executor
|
||||
# latency. Calling aflush() here would hide a broken resume() path.
|
||||
await asyncio.wait_for(flush_completed.wait(), timeout=5)
|
||||
restarted_view = BuzzSeenEventStore(path)
|
||||
assert await restarted_view.aseen(_CHANNEL_ID, "restart-event")
|
||||
finally:
|
||||
await channel.stop()
|
||||
|
||||
@ -294,6 +294,37 @@ class TestErrorObservationRetry:
|
||||
assert result == "all good"
|
||||
assert call_count == 1
|
||||
|
||||
def test_missing_recovery_session_is_recreated_once_and_reused(self, sandbox):
|
||||
"""An evicted lead recovery session must not poison every later call."""
|
||||
from agent_sandbox.core.api_error import ApiError
|
||||
|
||||
created_ids: list[str] = []
|
||||
exec_ids: list[str | None] = []
|
||||
sandbox._default_shell_corrupted = True
|
||||
sandbox._recovery_session_id = "evicted-lead-session"
|
||||
|
||||
def create_session(id, **kwargs):
|
||||
created_ids.append(id)
|
||||
return SimpleNamespace(data=SimpleNamespace(session_id=id))
|
||||
|
||||
def exec_command(command, **kwargs):
|
||||
session_id = kwargs.get("id")
|
||||
exec_ids.append(session_id)
|
||||
if session_id == "evicted-lead-session":
|
||||
raise ApiError(
|
||||
status_code=404,
|
||||
body={"message": f"Shell session not found: {session_id}"},
|
||||
)
|
||||
return SimpleNamespace(data=SimpleNamespace(output="healthy", exit_code=0))
|
||||
|
||||
sandbox._client.shell.create_session = create_session
|
||||
sandbox._client.shell.exec_command = exec_command
|
||||
|
||||
assert sandbox.execute_command("first") == "healthy"
|
||||
assert sandbox.execute_command("second") == "healthy"
|
||||
assert len(created_ids) == 1
|
||||
assert exec_ids == ["evicted-lead-session", created_ids[0], created_ids[0]]
|
||||
|
||||
|
||||
class TestScopedShellSessions:
|
||||
"""Concurrent subagents use independent persistent shell sessions (#5128)."""
|
||||
@ -410,6 +441,98 @@ class TestScopedShellSessions:
|
||||
sandbox.release_command_scope("subagent-a")
|
||||
assert cleaned_ids == created_ids
|
||||
|
||||
def test_missing_scoped_session_is_recreated_and_reused(self, sandbox):
|
||||
"""A server-side session loss must not pin the scope to a stale id."""
|
||||
from agent_sandbox.core.api_error import ApiError
|
||||
|
||||
created_ids: list[str] = []
|
||||
exec_ids: list[str] = []
|
||||
|
||||
def create_session(id, **kwargs):
|
||||
created_ids.append(id)
|
||||
return SimpleNamespace(data=SimpleNamespace(session_id=id))
|
||||
|
||||
def exec_command(command, **kwargs):
|
||||
exec_ids.append(kwargs["id"])
|
||||
if len(exec_ids) == 1:
|
||||
raise ApiError(
|
||||
headers={"server": "nginx/1.18.0 (Ubuntu)"},
|
||||
status_code=404,
|
||||
body={"success": False, "message": "Session not found", "data": None, "hint": None},
|
||||
)
|
||||
return SimpleNamespace(data=SimpleNamespace(output="ok", exit_code=0))
|
||||
|
||||
sandbox._client.shell.create_session = create_session
|
||||
sandbox._client.shell.exec_command = exec_command
|
||||
|
||||
assert sandbox.execute_command_in_scope("first", scope_id="subagent-a") == "ok"
|
||||
assert len(created_ids) == 2
|
||||
assert exec_ids == created_ids
|
||||
|
||||
assert sandbox.execute_command_in_scope("second", scope_id="subagent-a") == "ok"
|
||||
assert exec_ids[-1] == created_ids[1]
|
||||
|
||||
def test_missing_scoped_session_recovery_is_bounded(self, sandbox):
|
||||
"""A missing replacement session is reported after one recovery attempt."""
|
||||
from agent_sandbox.core.api_error import ApiError
|
||||
|
||||
created_ids: list[str] = []
|
||||
exec_ids: list[str] = []
|
||||
cleaned_ids: list[str] = []
|
||||
|
||||
def create_session(id, **kwargs):
|
||||
created_ids.append(id)
|
||||
return SimpleNamespace(data=SimpleNamespace(session_id=id))
|
||||
|
||||
def exec_command(command, **kwargs):
|
||||
exec_ids.append(kwargs["id"])
|
||||
raise ApiError(
|
||||
headers={"server": "nginx/1.18.0 (Ubuntu)"},
|
||||
status_code=404,
|
||||
body={"success": False, "message": "Session not found", "data": None},
|
||||
)
|
||||
|
||||
sandbox._client.shell.create_session = create_session
|
||||
sandbox._client.shell.exec_command = exec_command
|
||||
sandbox._client.shell.cleanup_session = lambda session_id, **kwargs: cleaned_ids.append(session_id)
|
||||
|
||||
result = sandbox.execute_command_in_scope("first", scope_id="subagent-a")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert len(created_ids) == 2
|
||||
assert exec_ids == created_ids
|
||||
assert cleaned_ids == [created_ids[1]]
|
||||
assert sandbox._scoped_shell_sessions["subagent-a"].session_id is None
|
||||
|
||||
def test_other_scoped_404_does_not_rotate_session(self, sandbox):
|
||||
"""Only the structured missing-session response is recoverable."""
|
||||
from agent_sandbox.core.api_error import ApiError
|
||||
|
||||
created_ids: list[str] = []
|
||||
exec_ids: list[str] = []
|
||||
|
||||
def create_session(id, **kwargs):
|
||||
created_ids.append(id)
|
||||
return SimpleNamespace(data=SimpleNamespace(session_id=id))
|
||||
|
||||
def exec_command(command, **kwargs):
|
||||
exec_ids.append(kwargs["id"])
|
||||
raise ApiError(
|
||||
headers={"server": "nginx/1.18.0 (Ubuntu)"},
|
||||
status_code=404,
|
||||
body={"success": False, "message": "Not Found", "data": None},
|
||||
)
|
||||
|
||||
sandbox._client.shell.create_session = create_session
|
||||
sandbox._client.shell.exec_command = exec_command
|
||||
|
||||
result = sandbox.execute_command_in_scope("first", scope_id="subagent-a")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert len(created_ids) == 1
|
||||
assert exec_ids == created_ids
|
||||
assert sandbox._scoped_shell_sessions["subagent-a"].session_id == created_ids[0]
|
||||
|
||||
def test_queued_command_cannot_restart_session_after_scope_release(self, sandbox):
|
||||
created_ids: list[str] = []
|
||||
executed_commands: list[str] = []
|
||||
@ -498,9 +621,47 @@ class TestScopedShellSessions:
|
||||
== "ok"
|
||||
)
|
||||
sandbox._client.bash.exec.assert_called_once()
|
||||
session_id = sandbox._client.bash.create_session.call_args.kwargs["session_id"]
|
||||
assert sandbox._client.bash.exec.call_args.kwargs["session_id"] == session_id
|
||||
sandbox._client.bash.close_session.assert_called_once_with(session_id)
|
||||
sandbox._client.shell.create_session.assert_not_called()
|
||||
assert sandbox._scoped_shell_sessions == {}
|
||||
|
||||
def test_env_session_cleanup_failure_does_not_mask_command_output(self, sandbox):
|
||||
sandbox._client.bash.exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(stdout="ok", stderr="", exit_code=0)))
|
||||
sandbox._client.bash.close_session = MagicMock(side_effect=RuntimeError("cleanup failed"))
|
||||
|
||||
assert sandbox.execute_command("echo $TOKEN", env={"TOKEN": "secret"}) == "ok"
|
||||
|
||||
def test_missing_session_with_failed_replacement_does_not_execute_third_time(self, sandbox):
|
||||
from agent_sandbox.core.api_error import ApiError
|
||||
|
||||
executions = 0
|
||||
|
||||
def exec_command(command, **kwargs):
|
||||
nonlocal executions
|
||||
executions += 1
|
||||
if executions == 1:
|
||||
raise ApiError(
|
||||
status_code=404,
|
||||
body={"message": "session not found while executing command"},
|
||||
)
|
||||
return SimpleNamespace(
|
||||
data=SimpleNamespace(
|
||||
output="'ErrorObservation' object has no attribute 'exit_code'",
|
||||
exit_code=None,
|
||||
)
|
||||
)
|
||||
|
||||
sandbox._client.shell.exec_command = exec_command
|
||||
sandbox._client.shell.create_session = lambda id, **kwargs: SimpleNamespace(data=SimpleNamespace(session_id=id))
|
||||
|
||||
result = sandbox.execute_command_in_scope("unsafe-to-repeat", scope_id="subagent-a")
|
||||
|
||||
assert "ErrorObservation" in result
|
||||
assert executions == 2
|
||||
assert sandbox._scoped_shell_sessions["subagent-a"].session_id is None
|
||||
|
||||
def test_closed_sandbox_rejects_new_scope_without_leaking_session(self, sandbox):
|
||||
client = sandbox._client
|
||||
|
||||
@ -594,6 +755,31 @@ class TestBashExecUnsupportedFailFast:
|
||||
assert second == "ok"
|
||||
assert sandbox._client.bash.exec.call_count == 2
|
||||
|
||||
def test_bash_exec_missing_session_retries_without_latching_unsupported(self, sandbox):
|
||||
"""A transient loss after explicit creation must retry once and keep the
|
||||
capability enabled for subsequent env-bearing commands."""
|
||||
from agent_sandbox.core.api_error import ApiError
|
||||
|
||||
missing = ApiError(status_code=404, body={"message": "Session not found: transient"})
|
||||
sandbox._client.bash.exec = MagicMock(side_effect=[missing, SimpleNamespace(data=SimpleNamespace(stdout="ok", stderr=None))])
|
||||
|
||||
assert sandbox.execute_command("cmd", env={"TOK": "v"}) == "ok"
|
||||
assert sandbox._bash_exec_unsupported is False
|
||||
assert sandbox._client.bash.exec.call_count == 2
|
||||
assert sandbox._client.bash.create_session.call_count == 2
|
||||
|
||||
def test_bash_exec_repeated_missing_session_does_not_latch_capability_gap(self, sandbox):
|
||||
from agent_sandbox.core.api_error import ApiError
|
||||
|
||||
missing = ApiError(status_code=404, body={"message": "Session not found"})
|
||||
sandbox._client.bash.exec = MagicMock(side_effect=missing)
|
||||
|
||||
out = sandbox.execute_command("cmd", env={"TOK": "v"})
|
||||
|
||||
assert out == "Error: bash.exec session disappeared after retry"
|
||||
assert sandbox._bash_exec_unsupported is False
|
||||
assert sandbox._client.bash.exec.call_count == 2
|
||||
|
||||
|
||||
class TestListDirSerialization:
|
||||
"""Verify that list_dir also acquires the lock."""
|
||||
|
||||
@ -1176,6 +1176,73 @@ def test_discover_returns_none_when_runtime_check_times_out(monkeypatch):
|
||||
assert backend.discover("sandbox-timeout") is None
|
||||
|
||||
|
||||
def test_discover_replaces_container_with_insufficient_shell_capacity(monkeypatch):
|
||||
backend = _backend_for_inspect_tests()
|
||||
backend._environment["MAX_SHELL_SESSIONS"] = "13"
|
||||
container_name = "sandbox-existing"
|
||||
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
||||
monkeypatch.setattr(
|
||||
backend,
|
||||
"_batch_inspect",
|
||||
lambda *_args, **_kwargs: {
|
||||
container_name: _ContainerInspection(
|
||||
created_at=1.0,
|
||||
host_port=18080,
|
||||
labels={
|
||||
"deerflow.role": "sandbox",
|
||||
"deerflow.sandbox_id": "existing",
|
||||
"deerflow.network_mode": "open",
|
||||
},
|
||||
image="sandbox:latest",
|
||||
networks=frozenset({"bridge"}),
|
||||
max_shell_sessions=10,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
info = backend.discover("existing")
|
||||
|
||||
assert info is not None
|
||||
assert info.requires_replacement is True
|
||||
assert info.sandbox_url == ""
|
||||
|
||||
|
||||
def test_list_running_marks_insufficient_shell_capacity_for_fenced_replacement(monkeypatch):
|
||||
backend = _backend_for_inspect_tests()
|
||||
backend._environment["MAX_SHELL_SESSIONS"] = "13"
|
||||
container_name = "sandbox-existing"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
assert cmd[:2] == ["docker", "ps"]
|
||||
return SimpleNamespace(stdout=f"{container_name}\n", stderr="", returncode=0)
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
monkeypatch.setattr(
|
||||
backend,
|
||||
"_batch_inspect",
|
||||
lambda *_args, **_kwargs: {
|
||||
container_name: _ContainerInspection(
|
||||
created_at=1.0,
|
||||
host_port=18080,
|
||||
labels={
|
||||
"deerflow.role": "sandbox",
|
||||
"deerflow.sandbox_id": "existing",
|
||||
"deerflow.network_mode": "open",
|
||||
},
|
||||
image="sandbox:latest",
|
||||
networks=frozenset({"bridge"}),
|
||||
max_shell_sessions=10,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
infos = backend.list_running()
|
||||
|
||||
assert len(infos) == 1
|
||||
assert infos[0].requires_replacement is True
|
||||
assert infos[0].sandbox_url == ""
|
||||
|
||||
|
||||
def test_restricted_discovery_uses_proxy_relay_port(monkeypatch):
|
||||
backend = _backend_for_inspect_tests()
|
||||
backend._network_mode = "allowlist"
|
||||
|
||||
@ -44,8 +44,12 @@ def test_load_config_preserves_thread_data_mounts_override(sandbox_overrides, ex
|
||||
monkeypatch.setattr(aio_mod, "get_app_config", lambda: app_config)
|
||||
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
||||
|
||||
assert provider._load_config()["thread_data_mounts"] is expected
|
||||
assert provider._load_config()["skills_container_path"] == "/mnt/skills"
|
||||
loaded = provider._load_config()
|
||||
|
||||
assert loaded["thread_data_mounts"] is expected
|
||||
assert loaded["skills_container_path"] == "/mnt/skills"
|
||||
assert loaded["max_shell_sessions"] is None
|
||||
assert "MAX_SHELL_SESSIONS" not in loaded["environment"]
|
||||
|
||||
|
||||
def test_load_config_snapshots_custom_skills_container_path(monkeypatch):
|
||||
@ -64,6 +68,102 @@ def test_load_config_snapshots_custom_skills_container_path(monkeypatch):
|
||||
assert provider._load_config()["skills_container_path"] == "/custom-skills"
|
||||
|
||||
|
||||
def test_load_config_sizes_aio_shell_capacity_for_subagent_runtime(monkeypatch):
|
||||
"""Twelve subagents must not exceed AIO 1.11's ten-session default."""
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
sandbox_config = SandboxConfig(
|
||||
use="deerflow.community.aio_sandbox:AioSandboxProvider",
|
||||
)
|
||||
app_config = SimpleNamespace(
|
||||
sandbox=sandbox_config,
|
||||
stream_bridge=None,
|
||||
subagent_runtime=SimpleNamespace(max_running=12),
|
||||
)
|
||||
monkeypatch.setattr(aio_mod, "get_app_config", lambda: app_config)
|
||||
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
||||
|
||||
loaded = provider._load_config()
|
||||
|
||||
assert loaded["max_shell_sessions"] == 13
|
||||
assert loaded["environment"]["MAX_SHELL_SESSIONS"] == str(loaded["max_shell_sessions"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("remote", [False, True], ids=["docker", "provisioner"])
|
||||
@pytest.mark.parametrize("persisted_capacity", [4, 9, 13])
|
||||
def test_backend_checks_runtime_minimum_without_overriding_image_default(monkeypatch, remote, persisted_capacity):
|
||||
"""Removing a four-session override must not reuse it for eight subagents."""
|
||||
from deerflow.community.aio_sandbox import aio_sandbox_provider as aio_mod
|
||||
from deerflow.community.aio_sandbox import local_backend as local_mod
|
||||
from deerflow.community.aio_sandbox import remote_backend as remote_mod
|
||||
|
||||
app_config = SimpleNamespace(
|
||||
sandbox=SandboxConfig(
|
||||
use="deerflow.community.aio_sandbox:AioSandboxProvider",
|
||||
container_prefix="sandbox",
|
||||
provisioner_url="http://provisioner:8002" if remote else None,
|
||||
environment={"MAX_SHELL_SESSIONS": "4"},
|
||||
),
|
||||
stream_bridge=None,
|
||||
subagent_runtime=SimpleNamespace(max_running=3),
|
||||
)
|
||||
monkeypatch.setattr(aio_mod, "get_app_config", lambda: app_config)
|
||||
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
||||
assert provider._load_config()["max_shell_sessions"] == 4
|
||||
app_config.subagent_runtime.max_running = 8
|
||||
app_config.sandbox.environment = {}
|
||||
provider._config = provider._load_config()
|
||||
assert provider._config["max_shell_sessions"] is None
|
||||
assert "MAX_SHELL_SESSIONS" not in provider._config["environment"]
|
||||
monkeypatch.setattr(local_mod.LocalContainerBackend, "_detect_runtime", lambda _self: "docker")
|
||||
backend = provider._create_backend()
|
||||
if remote:
|
||||
payload = {"sandbox_id": "example", "sandbox_url": "http://sandbox:8080", "max_shell_sessions": persisted_capacity}
|
||||
|
||||
def get(url, **_kwargs):
|
||||
data = {"sandboxes": [payload]} if url.endswith("/api/sandboxes") else payload
|
||||
return SimpleNamespace(status_code=200, raise_for_status=lambda: None, json=lambda: data)
|
||||
|
||||
monkeypatch.setattr(remote_mod.requests, "get", get)
|
||||
else:
|
||||
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
||||
monkeypatch.setattr(local_mod, "wait_for_sandbox_ready", lambda *_a, **_kw: True)
|
||||
monkeypatch.setattr(local_mod.subprocess, "run", lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="sandbox-example\n"))
|
||||
inspection = local_mod._ContainerInspection(
|
||||
created_at=1.0,
|
||||
host_port=18080,
|
||||
image="sandbox:latest",
|
||||
networks=frozenset({"bridge"}),
|
||||
labels={"deerflow.role": "sandbox", "deerflow.sandbox_id": "example", "deerflow.network_mode": "open"},
|
||||
max_shell_sessions=persisted_capacity,
|
||||
)
|
||||
monkeypatch.setattr(backend, "_batch_inspect", lambda *_a, **_kw: {"sandbox-example": inspection})
|
||||
|
||||
discovered = backend.discover("example")
|
||||
assert discovered is not None
|
||||
assert discovered.requires_replacement is (persisted_capacity < 9)
|
||||
listed = backend.list_running()
|
||||
assert len(listed) == 1
|
||||
assert listed[0].requires_replacement is (persisted_capacity < 9)
|
||||
|
||||
|
||||
def test_load_config_rejects_shell_capacity_below_subagent_runtime(monkeypatch):
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
sandbox_config = SandboxConfig(
|
||||
use="deerflow.community.aio_sandbox:AioSandboxProvider",
|
||||
environment={"MAX_SHELL_SESSIONS": "12"},
|
||||
)
|
||||
app_config = SimpleNamespace(
|
||||
sandbox=sandbox_config,
|
||||
stream_bridge=None,
|
||||
subagent_runtime=SimpleNamespace(max_running=12),
|
||||
)
|
||||
monkeypatch.setattr(aio_mod, "get_app_config", lambda: app_config)
|
||||
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
||||
|
||||
with pytest.raises(ValueError, match=r"at least subagent_runtime\.max_running \+ 1"):
|
||||
provider._load_config()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backend_is_local", "override", "expected"),
|
||||
[
|
||||
@ -979,6 +1079,33 @@ def test_remote_backend_create_prefers_explicit_user_id(monkeypatch):
|
||||
assert posted["json"]["include_legacy_skills"] is False
|
||||
|
||||
|
||||
def test_remote_backend_forwards_shell_capacity_to_provisioner(monkeypatch):
|
||||
remote_mod = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
|
||||
backend = remote_mod.RemoteSandboxBackend(
|
||||
"http://provisioner:8002",
|
||||
max_shell_sessions=13,
|
||||
)
|
||||
posted: dict = {}
|
||||
|
||||
class _Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"sandbox_url": "http://sandbox.local", "max_shell_sessions": 13}
|
||||
|
||||
def _post(url, json, timeout, headers=None): # noqa: A002 - mirrors requests.post kwarg
|
||||
posted.update({"url": url, "json": json, "timeout": timeout})
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr(remote_mod.requests, "post", _post)
|
||||
monkeypatch.setattr(remote_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
||||
|
||||
backend.create("thread-42", "sandbox-42", user_id="user-7")
|
||||
|
||||
assert posted["json"]["max_shell_sessions"] == 13
|
||||
|
||||
|
||||
def test_create_sandbox_requests_runtime_when_lark_installed(tmp_path, monkeypatch):
|
||||
"""The provider must request lark-cli runtime provisioning when Lark is installed."""
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
|
||||
@ -110,11 +110,18 @@ def test_aio_sandbox_env_routes_through_bash_exec() -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _FakeBash:
|
||||
def create_session(self, *, session_id):
|
||||
captured["created_session"] = session_id
|
||||
|
||||
def exec(self, *, command, env=None, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["env"] = env
|
||||
captured["exec_session"] = kwargs["session_id"]
|
||||
return SimpleNamespace(data=SimpleNamespace(stdout="ok", stderr=None))
|
||||
|
||||
def close_session(self, session_id):
|
||||
captured["closed_session"] = session_id
|
||||
|
||||
sbx = AioSandbox.__new__(AioSandbox)
|
||||
sbx._lock = __import__("threading").Lock()
|
||||
sbx._client = SimpleNamespace(bash=_FakeBash())
|
||||
@ -127,6 +134,7 @@ def test_aio_sandbox_env_routes_through_bash_exec() -> None:
|
||||
assert out == "ok"
|
||||
assert captured["command"] == "gh pr create"
|
||||
assert captured["env"] == {"GH_TOKEN": "tok-123"}
|
||||
assert captured["created_session"] == captured["exec_session"] == captured["closed_session"]
|
||||
|
||||
|
||||
def test_aio_sandbox_no_env_leaves_command_unchanged() -> None:
|
||||
|
||||
@ -4,13 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import requests
|
||||
from blockbuster import BlockBuster
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
@ -48,6 +51,194 @@ def test_provisioner_request_defaults_skills_container_path(provisioner_module)
|
||||
)
|
||||
|
||||
assert request.skills_container_path == "/mnt/skills"
|
||||
assert request.max_shell_sessions is None
|
||||
|
||||
|
||||
def test_provisioner_threads_shell_capacity_into_sandbox_pod(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
provisioner_module,
|
||||
) -> None:
|
||||
fake_core_v1 = _RecordingCoreV1(
|
||||
event_loop_thread_id=-1,
|
||||
ready_after_service_reads={"sandbox-capacity": 1},
|
||||
)
|
||||
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
|
||||
|
||||
response = provisioner_module.create_sandbox(
|
||||
provisioner_module.CreateSandboxRequest(
|
||||
sandbox_id="sandbox-capacity",
|
||||
thread_id="thread-1",
|
||||
max_shell_sessions=13,
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == "Running"
|
||||
pod = fake_core_v1.created_pod_specs["sandbox-capacity"]
|
||||
env = {item.name: item.value for item in (pod.spec.containers[0].env or [])}
|
||||
assert env["MAX_SHELL_SESSIONS"] == "13"
|
||||
|
||||
|
||||
def test_provisioner_rejects_insufficient_capacity_without_replacing_existing_pod(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
provisioner_module,
|
||||
) -> None:
|
||||
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
|
||||
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
|
||||
with pytest.raises(provisioner_module.HTTPException) as error:
|
||||
provisioner_module.create_sandbox(
|
||||
provisioner_module.CreateSandboxRequest(
|
||||
sandbox_id="sandbox-existing",
|
||||
thread_id="thread-1",
|
||||
max_shell_sessions=13,
|
||||
)
|
||||
)
|
||||
|
||||
assert error.value.status_code == 409
|
||||
assert "capacity" in error.value.detail
|
||||
assert fake_core_v1.created_pods == []
|
||||
assert fake_core_v1.pod_shell_capacities["sandbox-existing"] == 10
|
||||
assert "sandbox-existing" in fake_core_v1.service_sandboxes
|
||||
|
||||
|
||||
def test_capacity_replacement_recovers_when_service_outlives_old_pod(monkeypatch, provisioner_module):
|
||||
"""A create racing asynchronous deletion can leave a Service without a Pod."""
|
||||
core = _RecordingCoreV1(event_loop_thread_id=-1)
|
||||
core.pod_shell_capacities.pop("sandbox-existing")
|
||||
monkeypatch.setattr(provisioner_module, "core_v1", core)
|
||||
|
||||
def existing_service(*_args):
|
||||
raise ApiException(status=409)
|
||||
|
||||
monkeypatch.setattr(core, "create_namespaced_service", existing_service)
|
||||
result = provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="sandbox-existing", thread_id="thread-a", max_shell_sessions=13))
|
||||
assert result.max_shell_sessions == 13
|
||||
assert core.created_pods == ["sandbox-existing"]
|
||||
assert "sandbox-existing" in core.service_sandboxes
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [ApiException(status=403), ApiException(status=503), RuntimeError("invalid persisted capacity")])
|
||||
def test_capacity_read_failure_does_not_authorize_creation(monkeypatch, provisioner_module, failure):
|
||||
core = _RecordingCoreV1(event_loop_thread_id=-1)
|
||||
monkeypatch.setattr(provisioner_module, "core_v1", core)
|
||||
monkeypatch.setattr(provisioner_module, "_get_pod_shell_capacity", MagicMock(side_effect=failure))
|
||||
|
||||
with pytest.raises(provisioner_module.HTTPException) as error:
|
||||
provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="sandbox-existing", max_shell_sessions=13))
|
||||
|
||||
assert error.value.status_code == 500
|
||||
assert core.created_pods == []
|
||||
assert core.pod_shell_capacities["sandbox-existing"] == 10
|
||||
assert "sandbox-existing" in core.service_sandboxes
|
||||
|
||||
|
||||
def test_list_sandboxes_skips_invalid_capacity_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
provisioner_module,
|
||||
) -> None:
|
||||
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
|
||||
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
|
||||
|
||||
def invalid_capacity(_sandbox_id: str) -> int:
|
||||
raise RuntimeError("invalid capacity")
|
||||
|
||||
monkeypatch.setattr(
|
||||
provisioner_module,
|
||||
"_get_pod_shell_capacity",
|
||||
invalid_capacity,
|
||||
)
|
||||
|
||||
response = provisioner_module.list_sandboxes()
|
||||
|
||||
assert response == {"sandboxes": [], "count": 0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("async_acquire", [False, True], ids=["sync", "async"])
|
||||
async def test_capacity_upgrade_preserves_peer_pod_after_discovery_error(monkeypatch, tmp_path, provisioner_module, async_acquire):
|
||||
"""Discovery failure must not bypass ownership; a later safe retry replaces."""
|
||||
from test_sandbox_orphan_reconciliation import _make_provider_for_reconciliation, _make_shared_ownership_store
|
||||
|
||||
from deerflow.community.aio_sandbox import aio_sandbox_provider as provider_mod
|
||||
from deerflow.community.aio_sandbox import remote_backend as remote_mod
|
||||
from deerflow.community.aio_sandbox.ownership import compute_lease_ttl
|
||||
from deerflow.config.paths import Paths
|
||||
|
||||
shared = _make_shared_ownership_store()
|
||||
old = _make_provider_for_reconciliation(worker_id="old-gateway", store=shared)
|
||||
new = _make_provider_for_reconciliation(worker_id="new-gateway", store=shared)
|
||||
sid = "sandbox-existing"
|
||||
old._publish_ownership(sid)
|
||||
new._backend = remote_mod.RemoteSandboxBackend("http://provisioner:8002", max_shell_sessions=13)
|
||||
core = _RecordingCoreV1(event_loop_thread_id=threading.get_ident())
|
||||
monkeypatch.setattr(provisioner_module, "core_v1", core)
|
||||
deleted_under_lease = []
|
||||
discovery_failed = False
|
||||
|
||||
def response(status, payload):
|
||||
result = requests.Response()
|
||||
result.status_code = status
|
||||
result._content = json.dumps(payload).encode()
|
||||
return result
|
||||
|
||||
def get(_url, **_kwargs):
|
||||
nonlocal discovery_failed
|
||||
if not discovery_failed:
|
||||
discovery_failed = True
|
||||
return response(503, {"detail": "temporarily unavailable"})
|
||||
return response(200, provisioner_module.get_sandbox(sid).model_dump())
|
||||
|
||||
def post(_url, *, json, **_kwargs):
|
||||
try:
|
||||
result = provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(**json))
|
||||
except provisioner_module.HTTPException as exc:
|
||||
return response(exc.status_code, {"detail": exc.detail})
|
||||
return response(200, result.model_dump())
|
||||
|
||||
def delete(_url, **_kwargs):
|
||||
deleted_under_lease.append((shared.owner(sid), old._ownership.claim(sid)))
|
||||
return response(200, provisioner_module.destroy_sandbox(sid))
|
||||
|
||||
monkeypatch.setattr(requests, "get", get)
|
||||
monkeypatch.setattr(requests, "post", post)
|
||||
monkeypatch.setattr(requests, "delete", delete)
|
||||
monkeypatch.setattr(remote_mod, "user_should_see_legacy_skills", lambda _uid: False)
|
||||
monkeypatch.setattr(provider_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
||||
monkeypatch.setattr(provider_mod, "wait_for_sandbox_ready", lambda *_a, **_kw: True)
|
||||
monkeypatch.setattr(provider_mod, "wait_for_sandbox_ready_async", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(provider_mod, "AioSandbox", MagicMock())
|
||||
monkeypatch.setattr(new, "_get_extra_mounts", lambda *_a, **_kw: [])
|
||||
monkeypatch.setattr(new, "_lark_integration_active", lambda *_a: False)
|
||||
monkeypatch.setattr(new, "_lark_broker_active", lambda *_a: False)
|
||||
|
||||
async def acquire():
|
||||
if async_acquire:
|
||||
return await new._discover_or_create_with_lock_async("thread-a", sid, user_id="user-a")
|
||||
return await asyncio.to_thread(new._discover_or_create_with_lock, "thread-a", sid, user_id="user-a")
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="409"):
|
||||
await acquire()
|
||||
assert core.pod_shell_capacities[sid] == 10
|
||||
assert core.created_pods == []
|
||||
assert sid in core.service_sandboxes
|
||||
assert shared.owner(sid) == "old-gateway"
|
||||
|
||||
with pytest.raises(provider_mod.SandboxPolicyReplacementDeferredError):
|
||||
await acquire()
|
||||
old._ownership.release(sid)
|
||||
with pytest.raises(provider_mod.SandboxPolicyReplacementDeferredError):
|
||||
await acquire()
|
||||
assert deleted_under_lease == []
|
||||
|
||||
new._unowned_since[sid] = time.time() - compute_lease_ttl(new._ownership_config) - 1
|
||||
assert await acquire() == sid
|
||||
assert deleted_under_lease == [("new-gateway", False)]
|
||||
assert core.created_pods == [sid]
|
||||
assert core.pod_shell_capacities[sid] == 13
|
||||
assert shared.owner(sid) == "new-gateway"
|
||||
finally:
|
||||
old._acquire_serializer.close()
|
||||
new._acquire_serializer.close()
|
||||
|
||||
|
||||
class _RecordingCoreV1:
|
||||
@ -61,6 +252,10 @@ class _RecordingCoreV1:
|
||||
self.event_loop_thread_id = event_loop_thread_id
|
||||
self.thread_ids: list[int] = []
|
||||
self.service_sandboxes: set[str] = {"sandbox-existing"}
|
||||
self.pod_shell_capacities: dict[str, int] = {
|
||||
"sandbox-existing": 10,
|
||||
"sandbox-listed": 10,
|
||||
}
|
||||
self.ready_after_service_reads = ready_after_service_reads or {}
|
||||
self.service_read_failures = service_read_failures or {}
|
||||
self.service_read_counts: dict[str, int] = {}
|
||||
@ -94,13 +289,29 @@ class _RecordingCoreV1:
|
||||
|
||||
def read_namespaced_pod(self, _name: str, _namespace: str):
|
||||
self._record_k8s_call()
|
||||
return SimpleNamespace(status=SimpleNamespace(phase="Running"))
|
||||
sandbox_id = _name[len("sandbox-") :]
|
||||
capacity = self.pod_shell_capacities.get(sandbox_id)
|
||||
if capacity is None:
|
||||
raise ApiException(status=404)
|
||||
return SimpleNamespace(
|
||||
status=SimpleNamespace(phase="Running"),
|
||||
spec=SimpleNamespace(
|
||||
containers=[
|
||||
SimpleNamespace(
|
||||
name="sandbox",
|
||||
env=[SimpleNamespace(name="MAX_SHELL_SESSIONS", value=str(capacity))],
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def create_namespaced_pod(self, _namespace: str, pod) -> None:
|
||||
self._record_k8s_call()
|
||||
sandbox_id = pod.metadata.labels["sandbox-id"]
|
||||
self.created_pods.append(sandbox_id)
|
||||
self.created_pod_specs[sandbox_id] = pod
|
||||
env = {item.name: item.value for item in (pod.spec.containers[0].env or [])}
|
||||
self.pod_shell_capacities[sandbox_id] = int(env.get("MAX_SHELL_SESSIONS", "10"))
|
||||
|
||||
def create_namespaced_service(self, _namespace: str, service) -> None:
|
||||
self._record_k8s_call()
|
||||
@ -110,9 +321,11 @@ class _RecordingCoreV1:
|
||||
|
||||
def delete_namespaced_service(self, _name: str, _namespace: str) -> None:
|
||||
self._record_k8s_call()
|
||||
self.service_sandboxes.discard(_sandbox_id_from_service_name(_name))
|
||||
|
||||
def delete_namespaced_pod(self, _name: str, _namespace: str) -> None:
|
||||
self._record_k8s_call()
|
||||
self.pod_shell_capacities.pop(_name[len("sandbox-") :], None)
|
||||
|
||||
def list_namespaced_service(self, _namespace: str, *, label_selector: str):
|
||||
self._record_k8s_call()
|
||||
|
||||
@ -96,6 +96,33 @@ def test_provisioner_list_returns_sandbox_infos_and_filters_invalid_entries(monk
|
||||
assert infos[0].sandbox_url == "http://k3s:31001"
|
||||
|
||||
|
||||
def test_provisioner_list_marks_insufficient_shell_capacity_for_replacement(monkeypatch):
|
||||
backend = RemoteSandboxBackend(
|
||||
"http://provisioner:8002",
|
||||
max_shell_sessions=13,
|
||||
)
|
||||
|
||||
def mock_get(url: str, timeout: int, headers=None):
|
||||
return _StubResponse(
|
||||
payload={
|
||||
"sandboxes": [
|
||||
{
|
||||
"sandbox_id": "abc123",
|
||||
"sandbox_url": "http://k3s:31001",
|
||||
"max_shell_sessions": 10,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(requests, "get", mock_get)
|
||||
|
||||
infos = backend._provisioner_list()
|
||||
|
||||
assert len(infos) == 1
|
||||
assert infos[0].requires_replacement is True
|
||||
|
||||
|
||||
def test_provisioner_list_sends_auth_header_when_api_key_set(monkeypatch):
|
||||
backend = RemoteSandboxBackend("http://provisioner:8002", api_key="secret")
|
||||
captured: list[dict] = []
|
||||
@ -562,6 +589,63 @@ def test_provisioner_discover_returns_info_on_success(monkeypatch):
|
||||
assert info.sandbox_url == "http://k3s:31001"
|
||||
|
||||
|
||||
def test_provisioner_discover_marks_insufficient_shell_capacity_for_replacement(monkeypatch):
|
||||
backend = RemoteSandboxBackend(
|
||||
"http://provisioner:8002",
|
||||
max_shell_sessions=13,
|
||||
)
|
||||
|
||||
def mock_get(url: str, timeout: int, headers=None):
|
||||
return _StubResponse(
|
||||
payload={
|
||||
"sandbox_id": "abc123",
|
||||
"sandbox_url": "http://k3s:31001",
|
||||
"max_shell_sessions": 10,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(requests, "get", mock_get)
|
||||
|
||||
info = backend._provisioner_discover("abc123")
|
||||
|
||||
assert info is not None
|
||||
assert info.requires_replacement is True
|
||||
|
||||
|
||||
def test_provisioner_create_reports_version_skew_when_capacity_is_missing(monkeypatch):
|
||||
backend = RemoteSandboxBackend("http://provisioner:8002", max_shell_sessions=13)
|
||||
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
||||
|
||||
def mock_post(url: str, *, json: dict, headers=None, timeout: int):
|
||||
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
|
||||
|
||||
monkeypatch.setattr(requests, "post", mock_post)
|
||||
|
||||
with pytest.raises(RuntimeError, match="version skew"):
|
||||
backend._provisioner_create(None, "abc123")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reported_capacity", [4, 9, 13, None])
|
||||
def test_create_checks_runtime_minimum_without_sending_image_override(monkeypatch, reported_capacity):
|
||||
backend = RemoteSandboxBackend("http://provisioner:8002", required_shell_sessions=9)
|
||||
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
||||
|
||||
def post(_url, *, json, **_kwargs):
|
||||
assert "max_shell_sessions" not in json
|
||||
payload = {"sandbox_url": "http://sandbox:8080"}
|
||||
if reported_capacity is not None:
|
||||
payload["max_shell_sessions"] = reported_capacity
|
||||
return _StubResponse(payload=payload)
|
||||
|
||||
monkeypatch.setattr(requests, "post", post)
|
||||
if reported_capacity == 4:
|
||||
with pytest.raises(RuntimeError, match="insufficient shell-session capacity"):
|
||||
backend.create("thread-a", "example")
|
||||
else:
|
||||
# A legacy provisioner without metadata retains the known image default.
|
||||
assert backend.create("thread-a", "example").sandbox_url == "http://sandbox:8080"
|
||||
|
||||
|
||||
def test_provisioner_discover_returns_none_on_request_exception(monkeypatch):
|
||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
||||
|
||||
|
||||
@ -1538,7 +1538,12 @@ sandbox:
|
||||
#
|
||||
# # Optional: Environment variables to inject into the sandbox container
|
||||
# # Values starting with $ will be resolved from host environment variables
|
||||
# # AIO semver images default MAX_SHELL_SESSIONS to 10. DeerFlow leaves that
|
||||
# # image default unchanged while subagent_runtime.max_running + 1 fits, and
|
||||
# # otherwise injects the required value for local and provisioner sandboxes.
|
||||
# # An explicit value is allowed but must be at least max_running + 1.
|
||||
# # environment:
|
||||
# # MAX_SHELL_SESSIONS: "13" # Example for subagent_runtime.max_running: 12
|
||||
# # NODE_ENV: production
|
||||
# # DEBUG: "false"
|
||||
# # API_KEY: $MY_API_KEY # Reads from host's MY_API_KEY env var
|
||||
|
||||
@ -164,6 +164,17 @@ The provisioner is configured via environment variables (set in [docker-compose-
|
||||
| `NODE_HOST` | `host.docker.internal` | Hostname that backend containers use to reach host NodePorts; ignored when `SANDBOX_SERVICE_TYPE=ClusterIP` |
|
||||
| `K8S_API_SERVER` | (from kubeconfig) | Override K8s API server URL (e.g., `https://host.docker.internal:26443`) |
|
||||
|
||||
For new sandbox requests, the Gateway also sends the effective AIO shell-session
|
||||
capacity derived from `subagent_runtime.max_running`. The provisioner writes it
|
||||
to the sandbox Pod as `MAX_SHELL_SESSIONS`; requests from older Gateways omit the
|
||||
field and retain the image default. Discovery responses report the effective
|
||||
capacity. The Gateway replaces a lower-capacity Pod through its ownership-fenced
|
||||
replacement path once the previous owner and recovery grace permit it. A create
|
||||
request for an existing Pod with insufficient capacity returns HTTP 409; the
|
||||
provisioner does not delete an existing Pod to upgrade its capacity, including
|
||||
after a transient Gateway discovery failure. If the old Pod has already gone
|
||||
but its Service remains, create can safely provision the missing Pod.
|
||||
|
||||
### Custom sandbox image
|
||||
|
||||
Provisioner-created sandbox Pods use the provisioner's `SANDBOX_IMAGE` environment variable. This is separate from `sandbox.image` in `config.yaml`, which applies to local Docker or Apple Container mode.
|
||||
|
||||
@ -109,6 +109,7 @@ SAFE_THREAD_ID_PATTERN = r"^[A-Za-z0-9_-]{1,64}$"
|
||||
SAFE_USER_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
|
||||
DEFAULT_USER_ID = "default"
|
||||
DEFAULT_SKILLS_CONTAINER_PATH = "/mnt/skills"
|
||||
DEFAULT_MAX_SHELL_SESSIONS = 10
|
||||
MAX_EXTRA_MOUNTS = 10
|
||||
ALLOWED_EXTRA_MOUNT_PATHS = {
|
||||
"/mnt/acp-workspace",
|
||||
@ -483,12 +484,16 @@ class CreateSandboxRequest(BaseModel):
|
||||
# mounted into the sidecar only, never the sandbox. Supersedes the runtime
|
||||
# binary + credential mounts when enabled.
|
||||
provision_lark_cli_broker: bool = False
|
||||
# New Gateways size this from their process-wide subagent capacity. None
|
||||
# keeps requests from older Gateways and custom provisioner callers working.
|
||||
max_shell_sessions: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class SandboxResponse(BaseModel):
|
||||
sandbox_id: str
|
||||
sandbox_url: str
|
||||
status: str
|
||||
max_shell_sessions: int | None = None
|
||||
|
||||
|
||||
# ── K8s resource helpers ─────────────────────────────────────────────────
|
||||
@ -958,11 +963,27 @@ def _build_pod(
|
||||
skills_container_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
|
||||
provision_lark_cli_runtime: bool = False,
|
||||
provision_lark_cli_broker: bool = False,
|
||||
max_shell_sessions: int | None = None,
|
||||
) -> k8s_client.V1Pod:
|
||||
"""Construct a Pod manifest for a single sandbox."""
|
||||
init_containers = (
|
||||
_build_lark_cli_init_containers(provision_lark_cli_runtime, provision_lark_cli_broker) or None
|
||||
)
|
||||
sandbox_env: list[k8s_client.V1EnvVar] = []
|
||||
if max_shell_sessions is not None:
|
||||
sandbox_env.append(
|
||||
k8s_client.V1EnvVar(
|
||||
name="MAX_SHELL_SESSIONS",
|
||||
value=str(max_shell_sessions),
|
||||
)
|
||||
)
|
||||
if _lark_cli_broker_enabled(provision_lark_cli_broker):
|
||||
sandbox_env.append(
|
||||
k8s_client.V1EnvVar(
|
||||
name="DEERFLOW_LARK_BROKER_URL",
|
||||
value=LARK_BROKER_URL,
|
||||
)
|
||||
)
|
||||
return k8s_client.V1Pod(
|
||||
metadata=k8s_client.V1ObjectMeta(
|
||||
name=_pod_name(sandbox_id),
|
||||
@ -980,11 +1001,7 @@ def _build_pod(
|
||||
name="sandbox",
|
||||
image=SANDBOX_IMAGE,
|
||||
image_pull_policy="IfNotPresent",
|
||||
env=(
|
||||
[k8s_client.V1EnvVar(name="DEERFLOW_LARK_BROKER_URL", value=LARK_BROKER_URL)]
|
||||
if _lark_cli_broker_enabled(provision_lark_cli_broker)
|
||||
else None
|
||||
),
|
||||
env=sandbox_env or None,
|
||||
ports=[
|
||||
k8s_client.V1ContainerPort(
|
||||
name="http",
|
||||
@ -1129,6 +1146,24 @@ def _get_pod_phase(sandbox_id: str) -> str:
|
||||
return "NotFound"
|
||||
|
||||
|
||||
def _get_pod_shell_capacity(sandbox_id: str) -> int:
|
||||
"""Return the effective AIO shell capacity persisted in the sandbox Pod."""
|
||||
pod = core_v1.read_namespaced_pod(_pod_name(sandbox_id), K8S_NAMESPACE)
|
||||
containers = getattr(getattr(pod, "spec", None), "containers", None) or []
|
||||
sandbox_container = next((container for container in containers if getattr(container, "name", None) == "sandbox"), None)
|
||||
for env_var in getattr(sandbox_container, "env", None) or []:
|
||||
if getattr(env_var, "name", None) != "MAX_SHELL_SESSIONS":
|
||||
continue
|
||||
try:
|
||||
value = int(env_var.value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("Persisted sandbox has an invalid MAX_SHELL_SESSIONS value") from exc
|
||||
if value <= 0:
|
||||
raise RuntimeError("Persisted sandbox has an invalid MAX_SHELL_SESSIONS value")
|
||||
return value
|
||||
return DEFAULT_MAX_SHELL_SESSIONS
|
||||
|
||||
|
||||
# ── API endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -1169,9 +1204,10 @@ def create_sandbox(req: CreateSandboxRequest):
|
||||
)
|
||||
provision_lark_cli_runtime = req.provision_lark_cli_runtime
|
||||
provision_lark_cli_broker = req.provision_lark_cli_broker
|
||||
max_shell_sessions = req.max_shell_sessions
|
||||
|
||||
logger.info(
|
||||
"Received request to create sandbox '%s' for thread '%s' user '%s' include_legacy_skills=%s skills_container_path=%s provision_lark_cli_runtime=%s provision_lark_cli_broker=%s",
|
||||
"Received request to create sandbox '%s' for thread '%s' user '%s' include_legacy_skills=%s skills_container_path=%s provision_lark_cli_runtime=%s provision_lark_cli_broker=%s max_shell_sessions=%s",
|
||||
sandbox_id,
|
||||
thread_id,
|
||||
user_id,
|
||||
@ -1179,16 +1215,33 @@ def create_sandbox(req: CreateSandboxRequest):
|
||||
skills_container_path,
|
||||
_lark_cli_runtime_enabled(provision_lark_cli_runtime),
|
||||
_lark_cli_broker_enabled(provision_lark_cli_broker),
|
||||
max_shell_sessions,
|
||||
)
|
||||
|
||||
# ── Fast path: sandbox already exists ────────────────────────────
|
||||
existing_url = _sandbox_access_url(sandbox_id, tolerate_read_errors=True)
|
||||
if existing_url:
|
||||
return SandboxResponse(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=existing_url,
|
||||
status=_get_pod_phase(sandbox_id),
|
||||
)
|
||||
try:
|
||||
existing_shell_capacity = _get_pod_shell_capacity(sandbox_id)
|
||||
except (ApiException, RuntimeError) as exc:
|
||||
# A Service can outlive its old Pod during asynchronous replacement.
|
||||
# Only a confirmed missing Pod may fall through to creation.
|
||||
if not isinstance(exc, ApiException) or exc.status != 404:
|
||||
raise HTTPException(status_code=500, detail=f"Could not verify existing sandbox shell capacity: {exc}") from exc
|
||||
else:
|
||||
if max_shell_sessions is not None and existing_shell_capacity < max_shell_sessions:
|
||||
# Only the Gateway can fence replacement against active owners.
|
||||
# A transient discovery failure can route a live Pod through create.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Existing sandbox shell capacity is below the requested value; replacement must be coordinated by the Gateway",
|
||||
)
|
||||
return SandboxResponse(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=existing_url,
|
||||
status=_get_pod_phase(sandbox_id),
|
||||
max_shell_sessions=existing_shell_capacity,
|
||||
)
|
||||
|
||||
# ── Create Pod ───────────────────────────────────────────────────
|
||||
try:
|
||||
@ -1203,6 +1256,7 @@ def create_sandbox(req: CreateSandboxRequest):
|
||||
skills_container_path=skills_container_path,
|
||||
provision_lark_cli_runtime=provision_lark_cli_runtime,
|
||||
provision_lark_cli_broker=provision_lark_cli_broker,
|
||||
max_shell_sessions=max_shell_sessions,
|
||||
),
|
||||
)
|
||||
logger.info(f"Created Pod {_pod_name(sandbox_id)}")
|
||||
@ -1234,10 +1288,20 @@ def create_sandbox(req: CreateSandboxRequest):
|
||||
if not sandbox_url:
|
||||
raise HTTPException(status_code=500, detail="Service access URL was not available in time")
|
||||
|
||||
# A concurrent creator can win the 409 race with a lower-capacity Pod.
|
||||
# Never claim that the requested value was applied without reading it back.
|
||||
try:
|
||||
actual_shell_capacity = _get_pod_shell_capacity(sandbox_id)
|
||||
except (ApiException, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not verify created sandbox shell capacity: {exc}") from exc
|
||||
if max_shell_sessions is not None and actual_shell_capacity < max_shell_sessions:
|
||||
raise HTTPException(status_code=409, detail="Existing sandbox shell capacity is below the requested value")
|
||||
|
||||
return SandboxResponse(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=sandbox_url,
|
||||
status=_get_pod_phase(sandbox_id),
|
||||
max_shell_sessions=actual_shell_capacity,
|
||||
)
|
||||
|
||||
|
||||
@ -1279,6 +1343,7 @@ def get_sandbox(sandbox_id: str):
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=sandbox_url,
|
||||
status=_get_pod_phase(sandbox_id),
|
||||
max_shell_sessions=_get_pod_shell_capacity(sandbox_id),
|
||||
)
|
||||
|
||||
|
||||
@ -1301,11 +1366,20 @@ def list_sandboxes():
|
||||
sandbox_url = _url_from_service(svc, sid)
|
||||
if not sandbox_url:
|
||||
continue
|
||||
try:
|
||||
shell_capacity = _get_pod_shell_capacity(sid)
|
||||
except (ApiException, RuntimeError) as exc:
|
||||
if isinstance(exc, ApiException) and exc.status == 404:
|
||||
continue
|
||||
reason = getattr(exc, "reason", str(exc))
|
||||
logger.warning("Skipping sandbox %s while inspecting shell capacity: %s", sid, reason)
|
||||
continue
|
||||
sandboxes.append(
|
||||
SandboxResponse(
|
||||
sandbox_id=sid,
|
||||
sandbox_url=sandbox_url,
|
||||
status=_get_pod_phase(sid),
|
||||
max_shell_sessions=shell_capacity,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user