fix(sandbox): run scoped env commands concurrently (#7841)

* fix(sandbox): run scoped env commands concurrently

* test(sandbox): gate concurrent command admission deterministically

* fix(sandbox): keep releasing scopes visible to client close

* docs(sandbox): clarify scoped env ownership and capacity

* fix(sandbox): retain cancellation ownership while scopes drain

* fix(sandbox): bound Bash admission to provisioned session capacity
This commit is contained in:
qkycir 2026-11-09 20:18:30 +08:00 committed by GitHub
parent 3e59e22b4c
commit f9767c65d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1006 additions and 36 deletions

View File

@ -43,6 +43,10 @@ This section accumulates work toward the **2.3.0** milestone
### Fixed
- **sandbox:** AIO commands from independent subagents now run concurrently when
they carry per-call environment variables, including Lark CLI commands.
Commands within one subagent remain ordered and client cleanup drains them.
- **gateway:** Deleting a thread no longer reports success while its
conversation stays readable. When the checkpoint store failed during
`DELETE /api/threads/{id}`, the route logged it at DEBUG, removed the

View File

@ -3064,6 +3064,23 @@ For example, independent read-only research can run concurrently when the wall-c
### Sandbox & File System
AIO runs commands from independent subagents concurrently, including Lark CLI
and skill commands with per-call environment variables. Commands within one
subagent stay ordered, and each environment-bearing call uses a fresh session.
Environment-bearing calls share a per-sandbox Bash admission limit, configured
by `sandbox.environment.AIO_BASH_MAX_SESSIONS` (positive integer, default `50`).
Excess calls wait without creating a session and can be cancelled while waiting;
the slot is retained through session cleanup. This pool is separate from
`MAX_SHELL_SESSIONS`. Docker reuse checks the persisted container environment;
K8s reuse checks the provisioner's Pod-reported Bash capacity. Upgrade the
provisioner with the Gateway: missing capacity metadata blocks admission.
Cancellation remains routed to the running command while scope release or
sandbox close waits for it to finish and clean up.
Scope release waits for in-flight execution and cleanup without a separate drain
deadline. Allow for the remaining command timeout (600 seconds by default), plus
session/transport/recovery/cleanup time when setting operator-facing release
budgets; the HTTP cleanup timeout does not bound the preceding lock wait.
With local host bash enabled, the path guard permits division in simple shell
arithmetic and recognized single-quoted Python, Node.js and Perl programs.
Variable-built paths remain checked even when the filename is numeric or a

View File

@ -30,6 +30,26 @@ receipts fence reuse through existing quarantine. New output from a running
command needs its next offset; empty polls retain the previous offsets.
Markers support cooperative cleanup, not hostile-code isolation.
Scoped env calls register in the same scope table and hold its lock through
fresh bash creation, execution, polling and cleanup. Only that private path
skips the shared implicit-shell lock. Releasing entries reject admission and stay
visible to close until drained; queued calls recheck identity. Keep same-scope
ordering, fresh env sessions, cancellation ownership and closed admission.
Close also retains draining entries. Retire the command owner only after the
scope lock drains, under the registry identity check, so cancellation during
release/close reaches the active command and concurrent releases stay idempotent.
`scope_lock_held=True` requires ownership of the registered scope lock for the
whole invocation, including retries. Release has no separate drain deadline;
HTTP cleanup bounds do not bound that lock wait. A timed-out release must retain
ownership until work drains before client detach or reuse can be safe.
All env calls share a cancellable Bash semaphore, held through cleanup. Scoped
calls acquire it after the scope lock, then recheck registry admission; unscoped
calls acquire it after the shared lock. `AIO_BASH_MAX_SESSIONS` (default 50) sets
both provisioning and client limits. Docker reuse checks persisted env; remote
reuse requires Pod-attested capacity. Unknown metadata defers, never proves a
need for replacement. Keep this pool independent of persistent Shell capacity.
Regressions: `tests/test_aio_scoped_env_concurrency.py`, `tests/test_aio_bash_capacity.py`.
## Readiness polling
Build the async readiness client off the event loop. Drain construction and

View File

@ -5,6 +5,7 @@ import math
import threading
import time
import uuid
from contextlib import nullcontext
from dataclasses import dataclass, field
import httpx
@ -43,10 +44,11 @@ _BASH_EXEC_UNSUPPORTED_ERROR = (
@dataclass
class _ScopedShellSession:
"""One server-side shell session serialized within an agent execution."""
"""An execution's command lock and optional persistent shell session."""
lock: threading.Lock = field(default_factory=threading.Lock)
session_id: str | None = None
releasing: bool = False
@dataclass
@ -83,6 +85,7 @@ class AioSandbox(Sandbox):
request_headers: dict[str, str] | None = None,
default_command_timeout: float | None = None,
lark_cli_broker: bool | None = None,
max_bash_sessions: int = 50,
):
"""Initialize the AIO sandbox.
@ -99,8 +102,13 @@ class AioSandbox(Sandbox):
sandbox fail-closed, so a construction site that forgets to
thread the attested value cannot silently authorize the
plaintext credential-mount overlay.
max_bash_sessions: Bash admission limit, no greater than the
admitted runtime's AIO_BASH_MAX_SESSIONS (default 50).
"""
super().__init__(id)
if type(max_bash_sessions) is not int or max_bash_sessions <= 0:
raise ValueError("max_bash_sessions must be a positive integer")
self._bash_slots = threading.BoundedSemaphore(max_bash_sessions)
self.lark_cli_broker = lark_cli_broker
if default_command_timeout is None:
self._default_command_timeout = self._DEFAULT_HARD_TIMEOUT
@ -165,15 +173,14 @@ class AioSandbox(Sandbox):
cleanup is never blocked.
"""
# Close admission for scoped commands before draining their sessions.
# A scoped call that already registered itself remains in this snapshot
# and is joined through its per-scope lock below; later calls fail
# without creating an orphaned registry entry.
# Keep entries registered while draining so concurrent release still
# joins their locks and cancellation reaches the admitted command owner.
# Later calls fail without creating an orphaned registry entry.
with self._scope_registry_lock:
if self._closed:
return
self._closed = True
scoped_sessions = list(self._scoped_shell_sessions.items())
self._scoped_shell_sessions.clear()
for scope_id, scoped in scoped_sessions:
with scoped.lock:
if scoped.session_id is not None and self._client is not None:
@ -184,6 +191,10 @@ class AioSandbox(Sandbox):
request_options=self._bounded_cleanup_request_options(),
)
scoped.session_id = None
with self._scope_registry_lock:
if self._scoped_shell_sessions.get(scope_id) is scoped:
self._command_scopes.release(scope_id)
self._scoped_shell_sessions.pop(scope_id)
with self._lock:
if self._recovery_session_id is not None and self._client is not None:
@ -589,13 +600,13 @@ class AioSandbox(Sandbox):
*,
scope_id: str | None = None,
) -> str:
"""Run no-env commands in one persistent session per subagent run.
"""Serialize commands within a subagent scope, independently of others.
Commands within a scope remain serialized, while independent subagents
use distinct server-side sessions and can execute concurrently. Secret-
bearing commands keep the existing fresh ``bash.exec`` behavior.
bearing commands keep the per-call fresh ``bash.exec`` session semantics.
"""
if env or scope_id is None:
if scope_id is None:
return self.execute_command(command, env=env, timeout=timeout)
_validate_extra_env(env)
@ -607,18 +618,25 @@ class AioSandbox(Sandbox):
scope_id,
_ScopedShellSession(),
)
with command_lock(scoped.lock, current_command_scope(self)):
if scoped.releasing:
raise RuntimeError("sandbox command scope is no longer active")
with command_lock(scoped.lock, current_command_scope(self)), command_lock(self._bash_slots, current_command_scope(self)) if env else nullcontext():
# Registration and command execution are separated by the
# per-scope wait. Revalidate identity after that wait so a
# release/close which removed this exact scope is a hard
# lifecycle fence: queued callers cannot resurrect a session
# on an orphaned registry entry.
with self._scope_registry_lock:
if self._closed or self._scoped_shell_sessions.get(scope_id) is not scoped:
if self._closed or scoped.releasing or self._scoped_shell_sessions.get(scope_id) is not scoped:
raise RuntimeError("sandbox command scope is no longer active")
client = self._client
if client is None:
raise RuntimeError("sandbox client is closed")
if env:
# This registry entry also makes close()/scope release drain
# fresh bash work. Keep per-call env sessions independent of
# the shared implicit shell and of other execution scopes.
return self._execute_with_env(command, env, self._effective_command_timeout(timeout), scope_lock_held=True)
if scoped.session_id is None:
scoped.session_id = self._create_shell_session(client)
try:
@ -696,22 +714,40 @@ class AioSandbox(Sandbox):
return f"Error: {e}"
def release_command_scope(self, scope_id: str) -> None:
"""Release execution metadata and its explicit shell session."""
self._command_scopes.release(scope_id)
"""Fence new commands, drain in-flight work, then release the session.
Waiting for the scope lock has no separate deadline. An active command
can hold it for its remaining command timeout (600s by default), plus
session creation, transport/recovery and cleanup time. Callers holding
a thread-key serializer must account for this in their release budget.
Returning early would require retaining ownership until work is drained.
"""
with self._scope_registry_lock:
scoped = self._scoped_shell_sessions.pop(scope_id, None)
if scoped is None:
return
with scoped.lock:
if scoped.session_id is None or self._client is None:
scoped = self._scoped_shell_sessions.get(scope_id)
if scoped is None:
self._command_scopes.release(scope_id)
return
self._cleanup_session_best_effort(
self._client,
scoped.session_id,
context=f"execution scope {scope_id}",
request_options=self._bounded_cleanup_request_options(),
)
scoped.session_id = None
# Keep draining entries visible to close(), while fencing new
# and already-queued commands before waiting on their lock.
scoped.releasing = True
try:
with scoped.lock:
if scoped.session_id is None or self._client is None:
return
self._cleanup_session_best_effort(
self._client,
scoped.session_id,
context=f"execution scope {scope_id}",
request_options=self._bounded_cleanup_request_options(),
)
scoped.session_id = None
finally:
with self._scope_registry_lock:
if self._scoped_shell_sessions.get(scope_id) is scoped:
# Keep cancellation routed to the original owner until
# execution and cleanup drain. Only this entry may retire it.
self._command_scopes.release(scope_id)
self._scoped_shell_sessions.pop(scope_id)
@staticmethod
def _format_timeout_duration(timeout: float) -> str:
@ -979,11 +1015,16 @@ class AioSandbox(Sandbox):
command: str,
env: dict[str, str],
timeout: float,
*,
scope_lock_held: bool = False,
) -> str:
"""Execute a command with per-call environment variables injected.
The persistent-shell ``shell.exec_command`` API has no env parameter, so
injected commands use the ``bash.exec`` API which accepts per-command env.
If ``scope_lock_held`` is True, the caller must hold the registered
per-scope execution lock through this call, including recovery retries;
see ``_run_bash_exec`` for the drain contract.
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
@ -1010,10 +1051,10 @@ class AioSandbox(Sandbox):
"""
if self._bash_exec_unsupported:
return _BASH_EXEC_UNSUPPORTED_ERROR
output, status = self._run_bash_exec(command, env, timeout)
output, status = self._run_bash_exec(command, env, timeout, scope_lock_held=scope_lock_held)
if status in (None, "completed") and output and _ERROR_OBSERVATION_SIGNATURE in output:
logger.warning("ErrorObservation detected in bash.exec output, retrying on a fresh session")
retried, retry_status = self._run_bash_exec(command, env, timeout)
retried, retry_status = self._run_bash_exec(command, env, timeout, scope_lock_held=scope_lock_held)
if retry_status not in (None, "completed"):
return retried
if retried and _ERROR_OBSERVATION_SIGNATURE not in retried:
@ -1099,10 +1140,21 @@ class AioSandbox(Sandbox):
command: str,
env: dict[str, str],
timeout: float,
*,
scope_lock_held: bool = False,
) -> tuple[str, str | None]:
"""Single bash.exec invocation in an explicitly released fresh session."""
"""Single bash.exec invocation in an explicitly released fresh session.
``scope_lock_held=True`` requires the caller to hold its registered
``_ScopedShellSession.lock`` and a Bash permit for this entire invocation. Release/close
join that lock to drain work before detaching the client; the shared
lock is skipped only under this precondition.
"""
scope = current_command_scope(self)
with command_lock(self._lock, scope):
# Scoped callers already hold their registered execution lock, which
# close() and release_command_scope() drain before detaching the client.
# Unscoped calls retain the shared lock for that lifecycle guarantee.
with nullcontext() if scope_lock_held else command_lock(self._lock, scope), nullcontext() if scope_lock_held else command_lock(self._bash_slots, scope):
for attempt in range(2):
session_id: str | None = None
try:

View File

@ -365,6 +365,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
provisioner_url=provisioner_url,
api_key=api_key,
max_shell_sessions=self._config.get("max_shell_sessions"),
max_bash_sessions=self._config.get("max_bash_sessions", 50),
required_shell_sessions=self._config.get("required_shell_sessions", 0),
)
@ -405,6 +406,15 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
AioSandbox._DEFAULT_HARD_TIMEOUT,
)
max_running_subagents = int(getattr(getattr(config, "subagent_runtime", None), "max_running", 3))
try:
max_bash_sessions = int(environment.get("AIO_BASH_MAX_SESSIONS", "50"))
except (TypeError, ValueError) as exc:
raise ValueError("sandbox.environment.AIO_BASH_MAX_SESSIONS must be a positive integer") from exc
if max_bash_sessions <= 0:
raise ValueError("sandbox.environment.AIO_BASH_MAX_SESSIONS must be a positive integer")
# Explicitly configure the same budget used by client admission. Bash
# and persistent Shell have independent server-side session pools.
environment["AIO_BASH_MAX_SESSIONS"] = str(max_bash_sessions)
required_shell_sessions = max_running_subagents + _SHELL_SESSION_HEADROOM
configured_shell_sessions = environment.get("MAX_SHELL_SESSIONS")
if configured_shell_sessions is None:
@ -430,6 +440,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"thread_data_mounts": getattr(sandbox_config, "thread_data_mounts", None),
"environment": environment,
"max_shell_sessions": max_shell_sessions,
"max_bash_sessions": max_bash_sessions,
"required_shell_sessions": required_shell_sessions,
"network": sandbox_config.network.model_dump(),
"ownership": getattr(sandbox_config, "ownership", None),
@ -1910,6 +1921,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
base_url=info.sandbox_url,
request_headers=info.request_headers,
default_command_timeout=self._config.get("command_timeout", AioSandbox._DEFAULT_HARD_TIMEOUT),
max_bash_sessions=self._config.get("max_bash_sessions", 50),
lark_cli_broker=info.lark_cli_broker,
)
self._sandboxes[sandbox_id] = sandbox
@ -1963,6 +1975,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
base_url=info.sandbox_url,
request_headers=info.request_headers,
default_command_timeout=self._config.get("command_timeout", AioSandbox._DEFAULT_HARD_TIMEOUT),
max_bash_sessions=self._config.get("max_bash_sessions", 50),
lark_cli_broker=info.lark_cli_broker,
)
# Ownership first, so a failure cannot leave a tracked-but-unowned sandbox.
@ -2024,6 +2037,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
base_url=info.sandbox_url,
request_headers=info.request_headers,
default_command_timeout=self._config.get("command_timeout", AioSandbox._DEFAULT_HARD_TIMEOUT),
max_bash_sessions=self._config.get("max_bash_sessions", 50),
lark_cli_broker=info.lark_cli_broker,
)
key = self._thread_key(thread_id, self._effective_acquire_user_id(user_id)) if thread_id else None

View File

@ -36,6 +36,14 @@ logger = logging.getLogger(__name__)
_AIO_DEFAULT_MAX_SHELL_SESSIONS = 10
def _parse_bash_capacity(config: dict) -> int:
value = _extract_container_environment(config, "AIO_BASH_MAX_SESSIONS")
try:
return max(0, int(value)) if value is not None else 50
except (TypeError, ValueError):
return 0
class _ExistingRestrictedSandbox(RuntimeError):
def __init__(self, info: SandboxInfo):
super().__init__(f"restricted sandbox {info.sandbox_id} already exists")
@ -58,6 +66,7 @@ class _ContainerInspection:
networks: frozenset[str]
relay_token: str | None = None
max_shell_sessions: int | None = None
max_bash_sessions: int | None = None
container_id: str | None = None
@ -712,7 +721,13 @@ class LocalContainerBackend(SandboxBackend):
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
if actual < required:
return False
# A reused container may predate the current Bash environment override.
# Never admit it using the new client's larger semaphore budget.
required_bash = int(self._environment.get("AIO_BASH_MAX_SESSIONS", "50"))
actual_bash = inspection.max_bash_sessions if inspection.max_bash_sessions is not None else 50
return actual_bash >= required_bash
def _network_policy_digest(self) -> str:
allow_domains = self._network_config.get("allow_domains", [])
@ -1998,6 +2013,7 @@ class LocalContainerBackend(SandboxBackend):
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,
max_bash_sessions=_parse_bash_capacity(config),
container_id=entry.get("Id") if isinstance(entry.get("Id"), str) and entry.get("Id") else None,
)
return out

View File

@ -152,6 +152,7 @@ class RemoteSandboxBackend(SandboxBackend):
max_shell_sessions: int | None = None,
*,
required_shell_sessions: int = 0,
max_bash_sessions: int | None = None,
):
"""Initialize with the provisioner service URL and optional API key.
@ -163,10 +164,12 @@ class RemoteSandboxBackend(SandboxBackend):
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.
max_bash_sessions: Bash capacity to provision and attest before admission.
"""
self._provisioner_url = provisioner_url.rstrip("/")
self._api_key = api_key
self._max_shell_sessions = max_shell_sessions
self._max_bash_sessions = max_bash_sessions
self._required_shell_sessions = max(required_shell_sessions, max_shell_sessions or 0)
@property
@ -185,7 +188,17 @@ class RemoteSandboxBackend(SandboxBackend):
except (TypeError, ValueError):
return True
def _sandbox_info(self, payload: dict, sandbox_id: str) -> SandboxInfo:
def _requires_bash_capacity_replacement(self, payload: dict) -> bool:
if self._max_bash_sessions is None:
return False
reported = payload.get("max_bash_sessions")
if type(reported) is not int or reported <= 0:
# Unknown metadata is not proof that a healthy Pod needs deletion.
raise RuntimeError("Provisioner did not report valid max_bash_sessions; upgrade the provisioner before admitting Bash commands")
return reported < self._max_bash_sessions
def _sandbox_info(self, payload: dict, sandbox_id: str, *, check_bash_capacity: bool = True) -> SandboxInfo:
bash_replacement = check_bash_capacity and self._requires_bash_capacity_replacement(payload)
broker = payload.get("lark_cli_broker")
generation = payload.get("container_id")
return SandboxInfo(
@ -193,7 +206,7 @@ class RemoteSandboxBackend(SandboxBackend):
sandbox_url=payload["sandbox_url"],
container_id=generation if isinstance(generation, str) and generation else None,
lark_cli_broker=broker if isinstance(broker, bool) else None,
requires_replacement=self._requires_shell_capacity_replacement(payload),
requires_replacement=self._requires_shell_capacity_replacement(payload) or bash_replacement,
)
# ── SandboxBackend interface ──────────────────────────────────────────
@ -339,6 +352,8 @@ class RemoteSandboxBackend(SandboxBackend):
}
if self._max_shell_sessions is not None:
payload["max_shell_sessions"] = self._max_shell_sessions
if self._max_bash_sessions is not None:
payload["max_bash_sessions"] = self._max_bash_sessions
provisioner_extra_mounts = _provisioner_extra_mounts_payload(
extra_mounts,
skills_container_path=normalized_skills_container_path,
@ -356,7 +371,13 @@ class RemoteSandboxBackend(SandboxBackend):
)
resp.raise_for_status()
data = resp.json()
info = self._sandbox_info(data, sandbox_id)
info = self._sandbox_info(data, sandbox_id, check_bash_capacity=False)
try:
insufficient_bash = self._requires_bash_capacity_replacement(data)
except RuntimeError as exc:
raise SandboxCreationError(str(exc), info=info) from exc
if insufficient_bash:
raise SandboxCreationError(f"Provisioner returned sandbox {sandbox_id} with insufficient Bash-session capacity", info=info)
if self._max_shell_sessions is not None and "max_shell_sessions" not in data:
raise SandboxCreationError("Provisioner did not report max_shell_sessions; Gateway/provisioner version skew prevents shell-capacity validation", info=info)
if self._requires_shell_capacity_replacement(data):

View File

@ -0,0 +1,222 @@
"""Bash admission includes creation, execution and cleanup, independently of PTYs."""
import threading
from types import SimpleNamespace
import httpx
import pytest
from test_aio_scoped_env_concurrency import (
_join_observed_thread,
_ObservedScopeLock,
_start_observed_thread,
)
from test_aio_scoped_env_concurrency import (
paused_env_command as _paused_env_command,
)
paused_env_command = _paused_env_command
@pytest.mark.parametrize("paused_env_command", [1], indirect=True)
@pytest.mark.parametrize("cancel", [False, True])
@pytest.mark.parametrize("direct", [False, True])
def test_bash_capacity_waiter_is_cancellable_and_wakes_after_cleanup(paused_env_command, monkeypatch, cancel, direct):
from deerflow.sandbox.command_scope import CommandScopeCancelled, command_owner
case = paused_env_command
slots = _ObservedScopeLock(case.sandbox._bash_slots)
case.sandbox._bash_slots = slots
original = case.http_client._transport.handle_request
cleanup_entered = threading.Event()
cleanup_resume = threading.Event()
live_sessions = set()
def transport(request):
path = request.url.path
if path == "/v1/bash/sessions/create":
assert not live_sessions, "Client exceeded the one-slot Bash pool"
live_sessions.add("active")
if path.startswith("/v1/bash/sessions/") and path.endswith("/close"):
cleanup_entered.set()
assert cleanup_resume.wait(5)
live_sessions.clear()
return original(request)
monkeypatch.setattr(case.http_client._transport, "handle_request", transport)
def run_second():
if direct:
with command_owner(case.sandbox, "second"):
if cancel:
with pytest.raises(CommandScopeCancelled, match="cancelled"):
case.sandbox.execute_command("ordinary-command", env={"REQUEST_VALUE": "two"})
return "cancelled"
return case.sandbox.execute_command("ordinary-command", env={"REQUEST_VALUE": "two"})
return case.run(owner="second", value="two")
first = _start_observed_thread(case.run, name="first-env")
queued = None
try:
assert case.entered.wait(5)
queued = _start_observed_thread(run_second, name="queued-env")
assert slots.queued.wait(5)
assert len(case.executions) == 1
if cancel:
case.sandbox.request_command_cancel("second")
_join_observed_thread(queued)
assert "cancelled" in queued.results[0]
case.resume.set()
assert cleanup_entered.wait(5)
assert len(case.executions) == 1, "A permit must cover session cleanup"
cleanup_resume.set()
_join_observed_thread(first)
_join_observed_thread(queued)
assert first.results == ["done"]
if not cancel:
assert queued.results == ["done"]
assert len(case.executions) == 2
assert case.run(owner="third") == "done", "Cancellation must not leak a permit"
assert not live_sessions
finally:
case.resume.set()
cleanup_resume.set()
for worker in (first, queued):
if worker is not None:
_join_observed_thread(worker)
@pytest.mark.parametrize("capacity", [0, -1, True, 1.5, "2"])
def test_invalid_bash_capacity_rejected_before_client_creation(capacity):
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
with pytest.raises(ValueError, match="max_bash_sessions"):
AioSandbox("invalid", "http://sandbox.invalid", max_bash_sessions=capacity)
@pytest.mark.parametrize("configured,expected", [(None, 50), ("1", 1), ("72", 72)])
def test_provider_configures_bash_budget_independently_of_shell(monkeypatch, configured, expected):
from deerflow.community.aio_sandbox import aio_sandbox_provider as module
from deerflow.config.sandbox_config import SandboxConfig
config = SimpleNamespace(
sandbox=SandboxConfig(use="deerflow.community.aio_sandbox:AioSandboxProvider", environment={} if configured is None else {"AIO_BASH_MAX_SESSIONS": configured}),
subagent_runtime=SimpleNamespace(max_running=64),
stream_bridge=None,
)
monkeypatch.setattr(module, "get_app_config", lambda: config)
provider = module.AioSandboxProvider.__new__(module.AioSandboxProvider)
loaded = provider._load_config()
assert loaded["max_bash_sessions"] == expected
assert loaded["environment"]["AIO_BASH_MAX_SESSIONS"] == str(expected)
assert loaded["max_shell_sessions"] == 65
@pytest.mark.parametrize("configured", ["0", "-1", "1.5", "invalid"])
def test_provider_rejects_invalid_bash_budget(monkeypatch, configured):
from deerflow.community.aio_sandbox import aio_sandbox_provider as module
from deerflow.config.sandbox_config import SandboxConfig
monkeypatch.setattr(module, "get_app_config", lambda: SimpleNamespace(sandbox=SandboxConfig(use="test", environment={"AIO_BASH_MAX_SESSIONS": configured})))
with pytest.raises(ValueError, match="AIO_BASH_MAX_SESSIONS"):
module.AioSandboxProvider.__new__(module.AioSandboxProvider)._load_config()
@pytest.mark.parametrize("actual,compatible", [("1", False), ("50", True), ("72", True), ("invalid", False), ("0", False), (None, True)])
def test_local_reuse_validates_persisted_bash_budget(actual, compatible):
from deerflow.community.aio_sandbox.local_backend import LocalContainerBackend, _parse_bash_capacity
backend = LocalContainerBackend.__new__(LocalContainerBackend)
backend._environment = {"AIO_BASH_MAX_SESSIONS": "50"}
backend._required_shell_sessions = 1
inspection = SimpleNamespace(max_shell_sessions=10, max_bash_sessions=_parse_bash_capacity({"Env": [] if actual is None else [f"AIO_BASH_MAX_SESSIONS={actual}"]}))
assert backend._has_compatible_shell_capacity(inspection) is compatible
@pytest.mark.parametrize("actual", [None, "50", 0, -1, True])
def test_remote_unknown_capacity_is_not_assumed_or_replaced(actual):
from deerflow.community.aio_sandbox.remote_backend import RemoteSandboxBackend
backend = RemoteSandboxBackend("http://provisioner", max_bash_sessions=50)
with pytest.raises(RuntimeError, match="max_bash_sessions"):
backend._sandbox_info({"sandbox_url": "http://sandbox", "max_bash_sessions": actual}, "test")
@pytest.mark.parametrize("actual,replacement", [(1, True), (50, False), (72, False)])
def test_remote_reuse_validates_attested_bash_budget(actual, replacement):
from deerflow.community.aio_sandbox.remote_backend import RemoteSandboxBackend
backend = RemoteSandboxBackend("http://provisioner", max_bash_sessions=50)
assert backend._sandbox_info({"sandbox_url": "http://sandbox", "max_bash_sessions": actual}, "test").requires_replacement is replacement
@pytest.mark.parametrize("paused_env_command", [1], indirect=True)
@pytest.mark.parametrize("teardown", ["close", "release"])
def test_capacity_waiter_rechecks_teardown_before_creation(paused_env_command, teardown):
case = paused_env_command
slots = _ObservedScopeLock(case.sandbox._bash_slots)
case.sandbox._bash_slots = slots
first = _start_observed_thread(case.run, name="first-env")
queued = stopping = None
try:
assert case.entered.wait(5)
queued = _start_observed_thread(lambda: case.run(owner="second"), name="queued-env")
assert slots.queued.wait(5)
scope = case.sandbox._scoped_shell_sessions["first" if teardown == "close" else "second"]
observed = _ObservedScopeLock(scope.lock)
scope.lock = observed
action = case.sandbox.close if teardown == "close" else lambda: case.sandbox.release_command_scope("second")
stopping = _start_observed_thread(action, name="scope-teardown")
assert observed.teardown.wait(5)
case.resume.set()
for worker in (first, queued, stopping):
_join_observed_thread(worker)
assert "no longer active" in queued.results[0]
assert len(case.executions) == 1
finally:
case.resume.set()
for worker in (first, queued, stopping):
if worker is not None:
_join_observed_thread(worker)
@pytest.mark.parametrize("paused_env_command", [1], indirect=True)
@pytest.mark.parametrize("failed_path", ["/v1/bash/sessions/create", "/v1/bash/exec"])
def test_failed_bash_request_returns_capacity(paused_env_command, monkeypatch, failed_path):
case = paused_env_command
original = case.http_client._transport.handle_request
rejected = False
def transport(request):
nonlocal rejected
if request.url.path == failed_path and not rejected:
rejected = True
return httpx.Response(400, json={"message": "Invalid request"})
return original(request)
monkeypatch.setattr(case.http_client._transport, "handle_request", transport)
case.resume.set()
assert "400" in case.run()
assert case.run(owner="second") == "done"
@pytest.mark.parametrize("reported", [1, 50, None])
def test_remote_create_transmits_and_verifies_bash_budget(monkeypatch, reported):
from deerflow.community.aio_sandbox import remote_backend as module
from deerflow.community.aio_sandbox.backend import SandboxCreationError
backend = module.RemoteSandboxBackend("http://provisioner", max_bash_sessions=50)
monkeypatch.setattr(module, "user_should_see_legacy_skills", lambda _user: False)
requests = []
def post(url, **kwargs):
requests.append(kwargs["json"])
return SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"sandbox_url": "http://sandbox", "max_bash_sessions": reported, "container_id": "pod-uid"})
monkeypatch.setattr(module.requests, "post", post)
if reported == 50:
assert backend._provisioner_create("thread", "sandbox").container_id == "pod-uid"
else:
with pytest.raises(SandboxCreationError) as error:
backend._provisioner_create("thread", "sandbox")
assert error.value.info.container_id == "pod-uid"
assert requests[0]["max_bash_sessions"] == 50

View File

@ -1941,6 +1941,8 @@ class TestScopedShellSessions:
},
)
sandbox._client.shell.create_session.assert_not_called()
assert sandbox._scoped_shell_sessions["subagent-a"].session_id is None
sandbox.release_command_scope("subagent-a")
assert sandbox._scoped_shell_sessions == {}
def test_env_session_cleanup_failure_does_not_mask_command_output(self, sandbox):

View File

@ -104,10 +104,12 @@ def test_bash_command_timeout_rejects_non_finite_values(value):
SandboxConfig(bash_command_timeout=value)
def test_register_created_sandbox_forwards_configured_command_timeout(tmp_path):
@pytest.mark.parametrize("bash_capacity", [1, 50])
def test_register_created_sandbox_forwards_configured_command_timeout(tmp_path, bash_capacity):
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider = _make_provider(tmp_path)
provider._config["command_timeout"] = 42
provider._config["max_bash_sessions"] = bash_capacity
provider._warm_pool = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
@ -123,6 +125,7 @@ def test_register_created_sandbox_forwards_configured_command_timeout(tmp_path):
base_url="http://sandbox",
request_headers=info.request_headers,
default_command_timeout=42,
max_bash_sessions=bash_capacity,
lark_cli_broker=False,
)
@ -176,7 +179,7 @@ def test_backend_checks_runtime_minimum_without_overriding_image_default(monkeyp
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}
payload = {"sandbox_id": "example", "sandbox_url": "http://sandbox:8080", "max_shell_sessions": persisted_capacity, "max_bash_sessions": 50}
def get(url, **_kwargs):
data = {"sandboxes": [payload]} if url.endswith("/api/sandboxes") else payload

View File

@ -0,0 +1,532 @@
"""Scoped env commands run independently and drain before client teardown."""
import asyncio
import importlib
import json
import os
import shlex
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import httpx
import pytest
from agent_sandbox import Sandbox as SandboxClient
from support.shell import require_script_bash
from deerflow.runtime.secret_context import ACTIVE_SECRETS_CONTEXT_KEY
from deerflow.sandbox.command_scope import command_owner
from deerflow.sandbox.lease import run_sync_lifecycle_operation
@pytest.mark.parametrize("mode", ["plain", "active-skill-env", "lark-cli"])
def test_independent_subagent_commands_enter_distinct_sessions_concurrently(monkeypatch, tmp_path, mode):
aio = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox")
tools = importlib.import_module("deerflow.sandbox.tools")
app_config = importlib.import_module("deerflow.config.app_config")
native_bash = require_script_bash()
shim_bin = tmp_path / "bin"
shim_bin.mkdir()
shim = shim_bin / "lark-cli"
shim.write_text("#!/bin/sh\nsleep 0.4\nprintf done\n", encoding="utf-8")
shim.chmod(0o755)
bookkeeping = threading.Lock()
sessions = {}
in_flight = peak = 0
entered = threading.Event()
requests = []
def complete(session):
nonlocal in_flight
if session.get("finished"):
return
try:
# Keep each service-side operation admitted until all peers have
# entered. Correctness must not depend on their sleep windows
# overlapping on a busy host.
assert entered.wait(5), "Independent commands did not all reach the service before any completion"
stdout, stderr = session["process"].communicate(timeout=5)
session.update(stdout=stdout, stderr=stderr, finished=True)
finally:
with bookkeeping:
in_flight -= 1
def transport(request):
nonlocal in_flight, peak
path = request.url.path
payload = json.loads(request.content) if request.content else {}
with bookkeeping:
requests.append((request.method, path, payload))
if path == "/v1/shell/sessions/create":
return httpx.Response(200, json={"success": True, "data": {"session_id": payload["id"], "working_dir": "/mnt/user-data/workspace"}})
if path == "/v1/bash/sessions/create":
return httpx.Response(
200, json={"success": True, "data": {"session_id": payload["session_id"], "status": "ready", "working_dir": "/mnt/user-data/workspace", "created_at": "2026-11-03T00:00:00Z", "last_used_at": "2026-11-03T00:00:00Z"}}
)
if path in ("/v1/shell/exec", "/v1/bash/exec"):
session_id = payload.get("id") or payload.get("session_id")
assert session_id is not None
command = payload["command"].replace("cd /mnt/user-data/workspace;", f"cd {shlex.quote(tmp_path.as_posix())};")
environment = {**os.environ, **payload.get("env", {})}
if "PATH" in payload.get("env", {}):
environment["PATH"] = environment["PATH"].replace("/mnt/integrations/lark-cli/runtime/bin", shim_bin.as_posix())
process = subprocess.Popen([native_bash, "--noprofile", "--norc", "-c", command], cwd=tmp_path, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8")
session = {"process": process, "command": payload["command"], "id": session_id}
with bookkeeping:
sessions[session_id] = session
in_flight += 1
peak = max(peak, in_flight)
if in_flight == 3:
entered.set()
data = {"session_id": session_id, "command": payload["command"], "status": "running"}
if path == "/v1/bash/exec":
data["command_id"] = session_id
return httpx.Response(200, json={"success": True, "data": data})
if path == "/v1/shell/wait":
complete(sessions[payload["id"]])
return httpx.Response(200, json={"success": True, "data": {"status": "completed"}})
if path == "/v1/shell/view":
session = sessions[payload["id"]]
assert session["finished"]
return httpx.Response(
200,
json={
"success": True,
"data": {
"session_id": session["id"],
"status": "completed",
"command": session["command"],
"output": session["stdout"],
"exit_code": session["process"].returncode,
"console": [{"ps1": "$ ", "command": session["command"], "output": session["stdout"]}],
},
},
)
if path == "/v1/bash/output":
session = sessions[payload["session_id"]]
complete(session)
return httpx.Response(
200,
json={
"success": True,
"data": {
"session_id": session["id"],
"stdout": session["stdout"],
"stderr": session["stderr"],
"command": {"command_id": session["id"], "command": session["command"], "status": "completed", "exit_code": session["process"].returncode},
},
},
)
assert path.startswith("/v1/shell/sessions/") or (path.startswith("/v1/bash/sessions/") and path.endswith("/close"))
return httpx.Response(200, json={"success": True})
with httpx.Client(transport=httpx.MockTransport(transport)) as http_client:
sdk = SandboxClient(base_url="http://sandbox.invalid", httpx_client=http_client)
monkeypatch.setattr(aio, "AioSandboxClient", lambda **_kwargs: sdk)
monkeypatch.setattr(aio, "sandbox_http_trust_env", lambda _url: True)
sandbox = aio.AioSandbox("parallel-public-tool", "http://sandbox.invalid", lark_cli_broker=False)
monkeypatch.setattr(tools, "ensure_sandbox_initialized", lambda _runtime: sandbox)
monkeypatch.setattr(tools, "is_local_sandbox", lambda _runtime: False)
monkeypatch.setattr(app_config, "get_app_config", lambda: SimpleNamespace(sandbox=SimpleNamespace(bash_output_max_chars=20000, bash_command_timeout=30)))
start = threading.Barrier(3)
def worker(index):
owner = f"subagent:{index}"
context = {"user_id": "test-user", "sandbox_lease_owner_id": owner, "sandbox_command_scope_id": owner}
if mode == "active-skill-env":
context[ACTIVE_SECRETS_CONTEXT_KEY] = {"REQUEST_TEST_VALUE": "ordinary-test-value"}
runtime = SimpleNamespace(context=context, state={})
start.wait(timeout=5)
command = "lark-cli audit-wait" if mode == "lark-cli" else "sleep 0.4; printf done"
return tools.bash_tool.func(runtime, command)
started = time.perf_counter()
try:
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(worker, index) for index in range(3)]
results = [future.result(timeout=20) for future in futures]
elapsed = time.perf_counter() - started
assert results == ["done"] * 3
assert len(sessions) == 3, "Each operation received its own real SDK session identity"
print(
"AIO_ADMISSION_REGRESSION "
+ json.dumps(
{
"mode": mode,
"commands": 3,
"sleep_seconds": 0.4,
# Diagnostic only: this includes the admission gate and
# is not the separate ungated audit benchmark.
"gated_elapsed_seconds": round(elapsed, 4),
"peak_admitted_commands": peak,
"all_entered_together": entered.is_set(),
"exec_requests": sum(path in ("/v1/shell/exec", "/v1/bash/exec") for _, path, _ in requests),
}
)
)
assert entered.is_set() and peak == 3, "Independent fresh sessions were serialized before reaching the AIO service"
finally:
for session in sessions.values():
if session["process"].poll() is None:
session["process"].terminate()
session["process"].communicate(timeout=5)
sandbox.close()
class _ObservedScopeLock:
def __init__(self, lock):
self.lock = lock
self.queued = threading.Event()
self.teardown = threading.Event()
def acquire(self, *args, **kwargs):
if threading.current_thread().name == "queued-env":
self.queued.set()
if threading.current_thread().name == "scope-teardown":
self.teardown.set()
return self.lock.acquire(*args, **kwargs)
def release(self):
self.lock.release()
def __enter__(self):
self.acquire()
return self
def __exit__(self, *_args):
self.release()
@pytest.fixture
def paused_env_command(monkeypatch, request):
aio = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox")
entered = threading.Event()
resume = threading.Event()
client_closed = threading.Event()
requests = []
executions = []
control_sessions = set()
request_lock = threading.Lock()
def transport(request):
assert not client_closed.is_set(), "Client closed while an admitted command still owned it"
path = request.url.path
payload = json.loads(request.content) if request.content else {}
with request_lock:
requests.append((request.method, path, payload))
if path == "/v1/bash/sessions/create":
return httpx.Response(200, json={"success": True, "data": {"session_id": payload["session_id"], "status": "ready", "working_dir": "/home/user", "created_at": "2026-11-03T00:00:00Z", "last_used_at": "2026-11-03T00:00:00Z"}})
if path == "/v1/bash/exec":
with request_lock:
executions.append(payload)
return httpx.Response(200, json={"success": True, "data": {"session_id": payload["session_id"], "command_id": payload["session_id"], "command": payload["command"], "status": "running"}})
if path == "/v1/bash/output":
execution = next(item for item in executions if item["session_id"] == payload["session_id"])
if execution is executions[0]:
entered.set()
assert resume.wait(5), "Test never released the first command"
assert not client_closed.is_set()
return httpx.Response(
200, json={"success": True, "data": {"session_id": payload["session_id"], "stdout": "done", "command": {"command_id": payload["session_id"], "command": execution["command"], "status": "completed", "exit_code": 0}}}
)
if path.startswith("/v1/bash/sessions/") and path.endswith("/close"):
return httpx.Response(200, json={"success": True})
if path == "/v1/shell/sessions/create":
control_sessions.add(payload["id"])
return httpx.Response(200, json={"success": True, "data": {"session_id": payload["id"], "working_dir": "/home/user"}})
if path == "/v1/shell/exec":
assert payload["id"] in control_sessions
assert payload["command"].startswith("python3 -c ")
return httpx.Response(200, json={"success": True, "data": {"session_id": payload["id"], "command": payload["command"], "status": "running"}})
if path == "/v1/shell/view":
assert payload["id"] in control_sessions
return httpx.Response(200, json={"success": True, "data": {"session_id": payload["id"], "output": '{"remaining":0}', "status": "completed", "exit_code": 0}})
assert request.method == "DELETE" and path.removeprefix("/v1/shell/sessions/") in control_sessions
return httpx.Response(200, json={"success": True})
http_transport = httpx.MockTransport(transport)
real_close = http_transport.close
def close():
client_closed.set()
real_close()
monkeypatch.setattr(http_transport, "close", close)
http_client = httpx.Client(transport=http_transport)
sdk = SandboxClient(base_url="http://sandbox.invalid", httpx_client=http_client)
monkeypatch.setattr(aio, "AioSandboxClient", lambda **_kwargs: sdk)
monkeypatch.setattr(aio, "sandbox_http_trust_env", lambda _url: True)
options = {"max_bash_sessions": request.param} if hasattr(request, "param") else {}
sandbox = aio.AioSandbox("scoped-env-lifecycle", "http://sandbox.invalid", **options)
def run(owner="first", value="one"):
with command_owner(sandbox, owner):
return sandbox.execute_command_in_scope("ordinary-command", env={"REQUEST_VALUE": value}, scope_id=owner)
yield SimpleNamespace(sandbox=sandbox, entered=entered, resume=resume, client_closed=client_closed, http_client=http_client, requests=requests, executions=executions, controls=control_sessions, run=run)
resume.set()
sandbox.close()
http_client.close()
def _start_observed_thread(function, *, name):
results = []
errors = []
def run():
try:
results.append(function())
except BaseException as error:
errors.append(error)
thread = threading.Thread(target=run, name=name)
thread.start()
return SimpleNamespace(thread=thread, results=results, errors=errors)
def _join_observed_thread(worker):
worker.thread.join(5)
assert not worker.thread.is_alive()
assert not worker.errors
def test_env_commands_in_one_scope_remain_serialized(paused_env_command):
case = paused_env_command
first = _start_observed_thread(case.run, name="first-env")
second = None
try:
assert case.entered.wait(5)
scoped = case.sandbox._scoped_shell_sessions["first"]
observed = _ObservedScopeLock(scoped.lock)
scoped.lock = observed
second = _start_observed_thread(lambda: case.run(value="two"), name="queued-env")
assert observed.queued.wait(5)
assert len(case.executions) == 1
case.resume.set()
_join_observed_thread(first)
_join_observed_thread(second)
assert first.results == second.results == ["done"]
assert [item["env"] for item in case.executions] == [{"REQUEST_VALUE": "one"}, {"REQUEST_VALUE": "two"}]
assert len({item["session_id"] for item in case.executions}) == 2
assert not case.controls
finally:
case.resume.set()
_join_observed_thread(first)
if second is not None:
_join_observed_thread(second)
@pytest.mark.parametrize("teardown", ["close", "release"])
def test_teardown_drains_env_and_rejects_already_queued_commands(paused_env_command, teardown):
case = paused_env_command
first = _start_observed_thread(case.run, name="first-env")
queued = stopping = None
try:
assert case.entered.wait(5)
scoped = case.sandbox._scoped_shell_sessions["first"]
observed = _ObservedScopeLock(scoped.lock)
scoped.lock = observed
queued = _start_observed_thread(case.run, name="queued-env")
assert observed.queued.wait(5)
action = case.sandbox.close if teardown == "close" else lambda: case.sandbox.release_command_scope("first")
stopping = _start_observed_thread(action, name="scope-teardown")
assert observed.teardown.wait(5)
assert not case.client_closed.is_set()
assert stopping.thread.is_alive()
if teardown == "close":
assert case.run(owner="new") == "Error: sandbox client is closed"
case.resume.set()
for worker in (first, queued, stopping):
_join_observed_thread(worker)
assert first.results == ["done"]
assert queued.results == ["Error: sandbox command scope is no longer active"]
assert len(case.executions) == 1
assert case.client_closed.is_set() is (teardown == "close")
assert not case.sandbox._scoped_shell_sessions
finally:
case.resume.set()
for worker in (first, queued, stopping):
if worker is not None:
_join_observed_thread(worker)
def test_cancelled_env_waiter_never_submits_a_command(paused_env_command):
case = paused_env_command
first = _start_observed_thread(case.run, name="first-env")
queued = None
try:
assert case.entered.wait(5)
scoped = case.sandbox._scoped_shell_sessions["first"]
observed = _ObservedScopeLock(scoped.lock)
scoped.lock = observed
queued = _start_observed_thread(case.run, name="queued-env")
assert observed.queued.wait(5)
case.sandbox.request_command_cancel("first")
_join_observed_thread(queued)
assert "cancelled" in queued.results[0]
assert len(case.executions) == 1
assert not case.controls
finally:
case.resume.set()
_join_observed_thread(first)
if queued is not None:
_join_observed_thread(queued)
@pytest.mark.parametrize("close_first", [False, True])
@pytest.mark.parametrize("release_count", [1, 2])
def test_cancel_reaches_env_command_while_scope_drains(paused_env_command, close_first, release_count):
case = paused_env_command
first = _start_observed_thread(case.run, name="first-env")
workers = []
progress = {name: threading.Event() for name in ["close", "release-0", "release-1"]}
class DrainLock(_ObservedScopeLock):
def acquire(self, *args, **kwargs):
if event := progress.get(threading.current_thread().name):
event.set()
return super().acquire(*args, **kwargs)
try:
assert case.entered.wait(5)
owner = case.sandbox._command_scopes.find("first")
scoped = case.sandbox._scoped_shell_sessions["first"]
scoped.lock = DrainLock(scoped.lock)
if close_first:
workers.append(_start_observed_thread(case.sandbox.close, name="close"))
assert progress["close"].wait(5)
for index in range(release_count):
workers.append(_start_observed_thread(lambda: case.sandbox.release_command_scope("first"), name=f"release-{index}"))
assert progress[f"release-{index}"].wait(5), "Release must join the scope even when close is already draining it"
case.sandbox.request_command_cancel("first")
assert owner.cancelled.is_set(), "Cancellation was routed to a replacement owner instead of the running command"
assert case.sandbox._command_scopes.find("first") is owner
case.resume.set()
for worker in [first, *workers]:
_join_observed_thread(worker)
assert "cancelled" in first.results[0]
assert owner.cleaned.is_set()
assert len(case.controls) == 1
assert sum(path.endswith("/close") for _, path, _ in case.requests) == 1
assert case.sandbox._command_scopes.find("first") is None
assert not case.sandbox._scoped_shell_sessions
finally:
case.resume.set()
for worker in [first, *workers]:
_join_observed_thread(worker)
def test_cancelling_one_env_scope_preserves_another_scopes_command(paused_env_command):
case = paused_env_command
first = _start_observed_thread(case.run, name="first-env")
second = None
try:
assert case.entered.wait(5)
second = _start_observed_thread(lambda: case.run(owner="second", value="two"), name="second-env")
_join_observed_thread(second)
assert second.results == ["done"]
case.sandbox.request_command_cancel("first")
case.resume.set()
_join_observed_thread(first)
assert "cancelled" in first.results[0]
assert len(case.executions) == 2
assert len(case.controls) == 1
assert not case.sandbox.requires_container_recycle
finally:
case.resume.set()
_join_observed_thread(first)
if second is not None:
_join_observed_thread(second)
@pytest.mark.asyncio
async def test_repeated_async_cancellation_drains_scoped_env_cleanup(paused_env_command):
case = paused_env_command
task = asyncio.create_task(run_sync_lifecycle_operation(case.run, _on_cancel=lambda: case.sandbox.request_command_cancel("first")))
try:
assert await asyncio.to_thread(case.entered.wait, 5)
task.cancel()
await asyncio.sleep(0)
task.cancel()
assert not task.done()
assert not case.client_closed.is_set()
case.resume.set()
with pytest.raises(asyncio.CancelledError):
await task
assert len(case.controls) == 1
assert sum(path.startswith("/v1/bash/sessions/") and path.endswith("/close") for _, path, _ in case.requests) == 1
assert case.run(owner="second", value="two") == "done"
assert len(case.executions) == 2
assert not case.sandbox.requires_container_recycle
finally:
case.resume.set()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.parametrize("release_count", [1, 2])
def test_releasing_env_scope_stays_visible_to_concurrent_close(paused_env_command, monkeypatch, release_count):
case = paused_env_command
first = _start_observed_thread(case.run, name="first-env")
queued = closing = other = None
releases = []
progress = {f"release-{index}": threading.Event() for index in range(release_count)}
progress["close"] = threading.Event()
class OverlapLock(_ObservedScopeLock):
def acquire(self, *args, **kwargs):
name = threading.current_thread().name
if name in progress:
progress[name].set()
return super().acquire(*args, **kwargs)
real_client_close = case.http_client.close
def client_close():
# On the unfixed path close skips the popped releasing scope, detaches
# _client, and reaches this callback instead of waiting on its lock.
progress["close"].set()
real_client_close()
monkeypatch.setattr(case.http_client, "close", client_close)
try:
assert case.entered.wait(5)
scoped = case.sandbox._scoped_shell_sessions["first"]
observed = OverlapLock(scoped.lock)
scoped.lock = observed
# A prior no-env call can leave a persistent shell in the same scope.
scoped.session_id = "retained-shell"
case.controls.add("retained-shell")
queued = _start_observed_thread(case.run, name="queued-env")
assert observed.queued.wait(5)
for index in range(release_count):
release = _start_observed_thread(lambda: case.sandbox.release_command_scope("first"), name=f"release-{index}")
releases.append(release)
assert progress[f"release-{index}"].wait(5)
assert case.run() == "Error: sandbox command scope is no longer active"
other = _start_observed_thread(lambda: case.run(owner="other", value="two"), name="other-env")
_join_observed_thread(other)
assert other.results == ["done"], "Releasing one scope must not serialize an independent scope"
closing = _start_observed_thread(case.sandbox.close, name="close")
assert progress["close"].wait(5)
assert case.sandbox._client is not None, "close detached the client while a releasing scope still had an admitted command"
assert not case.client_closed.is_set()
assert closing.thread.is_alive()
case.resume.set()
for worker in (first, queued, *releases, closing):
_join_observed_thread(worker)
assert first.results == ["done"]
assert queued.results == ["Error: sandbox command scope is no longer active"]
assert len(case.executions) == 2
assert sum(method == "DELETE" and path.endswith("/retained-shell") for method, path, _ in case.requests) == 1
assert sum(path.endswith("/close") for _, path, _ in case.requests) == 2
assert not case.sandbox._scoped_shell_sessions
assert case.client_closed.is_set()
finally:
case.resume.set()
for worker in (first, queued, other, *releases, closing):
if worker is not None:
_join_observed_thread(worker)

View File

@ -39,6 +39,7 @@ def _new_aio_sandbox_with_session_state():
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox, _SessionCreationState
sbx = AioSandbox.__new__(AioSandbox)
sbx._bash_slots = threading.BoundedSemaphore(50)
sbx._session_creation_state_lock = threading.Lock()
sbx._shell_session_creation_state = _SessionCreationState()
sbx._bash_session_creation_state = _SessionCreationState()

View File

@ -103,6 +103,37 @@ def test_provisioner_threads_shell_capacity_into_sandbox_pod(
assert env["MAX_SHELL_SESSIONS"] == "13"
@pytest.mark.parametrize("capacity", [1, 50, 72])
def test_provisioner_attests_bash_capacity_from_created_and_reused_pod(monkeypatch, provisioner_module, capacity):
core = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", core)
request = provisioner_module.CreateSandboxRequest(sandbox_id="bash-capacity", max_bash_sessions=capacity)
response = provisioner_module.create_sandbox(request)
assert response.max_bash_sessions == capacity
pod = core.created_pod_specs["bash-capacity"]
assert {item.name: item.value for item in pod.spec.containers[0].env}["AIO_BASH_MAX_SESSIONS"] == str(capacity)
assert provisioner_module.create_sandbox(request).max_bash_sessions == capacity
assert len(core.created_pods) == 1
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.create_sandbox(request.model_copy(update={"max_bash_sessions": capacity + 1}))
assert error.value.status_code == 409
assert len(core.created_pods) == 1
def test_bash_capacity_already_exists_race_reads_winning_pod(monkeypatch, provisioner_module):
core = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", core)
# First observation misses the existing Service. The Pod creation loses
# to a peer's default-capacity Pod, so requested capacity is not evidence.
reads = iter([None, "http://sandbox"])
monkeypatch.setattr(provisioner_module, "_sandbox_access_url", lambda *_a, **_kw: next(reads))
monkeypatch.setattr(core, "create_namespaced_pod", MagicMock(side_effect=ApiException(status=409)))
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="sandbox-existing", max_bash_sessions=72))
assert error.value.status_code == 409
assert core.created_pods == []
def test_broker_request_without_image_cannot_mount_credentials(monkeypatch, provisioner_module):
core = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", core)

View File

@ -70,7 +70,11 @@ sandbox:
loaded = provider._load_config()
assert loaded["environment"] == _RESOLVED
expected = dict(_RESOLVED)
if class_name == "AioSandboxProvider":
expected["AIO_BASH_MAX_SESSIONS"] = "50"
assert loaded["environment"] == expected
assert app_config.sandbox.environment == _RESOLVED
@pytest.mark.usefixtures("secret_env")

View File

@ -1853,6 +1853,7 @@ sandbox:
# # 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
# # AIO_BASH_MAX_SESSIONS: "50" # Separate Bash pool; excess env calls wait (cancellable)
# # NODE_ENV: production
# # DEBUG: "false"
# # API_KEY: $MY_API_KEY # Reads from host's MY_API_KEY env var

View File

@ -568,6 +568,7 @@ class CreateSandboxRequest(BaseModel):
# 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)
max_bash_sessions: int | None = Field(default=None, gt=0)
class SandboxResponse(BaseModel):
@ -575,6 +576,7 @@ class SandboxResponse(BaseModel):
sandbox_url: str
status: str
max_shell_sessions: int | None = None
max_bash_sessions: int | None = None
container_id: str | None = None
lark_cli_broker: bool | None = None
@ -1039,6 +1041,7 @@ def _build_pod(
provision_lark_cli_runtime: bool = False,
provision_lark_cli_broker: bool = False,
max_shell_sessions: int | None = None,
max_bash_sessions: int | None = None,
) -> k8s_client.V1Pod:
"""Construct a Pod manifest for a single sandbox."""
_validate_lark_provisioning_request(provision_lark_cli_runtime, provision_lark_cli_broker)
@ -1046,6 +1049,8 @@ def _build_pod(
_build_lark_cli_init_containers(provision_lark_cli_runtime, provision_lark_cli_broker) or None
)
sandbox_env: list[k8s_client.V1EnvVar] = []
if max_bash_sessions is not None:
sandbox_env.append(k8s_client.V1EnvVar(name="AIO_BASH_MAX_SESSIONS", value=str(max_bash_sessions)))
if max_shell_sessions is not None:
sandbox_env.append(
k8s_client.V1EnvVar(
@ -1227,17 +1232,37 @@ def _pod_shell_capacity(pod) -> int:
return DEFAULT_MAX_SHELL_SESSIONS
def _pod_bash_capacity(pod) -> int:
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) != "AIO_BASH_MAX_SESSIONS":
continue
try:
value = int(env_var.value)
except (TypeError, ValueError) as exc:
raise RuntimeError("Persisted sandbox has an invalid AIO_BASH_MAX_SESSIONS value") from exc
if value <= 0:
raise RuntimeError("Persisted sandbox has an invalid AIO_BASH_MAX_SESSIONS value")
return value
return 50
def _sandbox_response(
sandbox_id: str,
sandbox_url: str,
*,
required_capacity: int | None = None,
required_bash_capacity: int | None = None,
required_broker: bool | None = None,
) -> SandboxResponse:
# Attest mode, generation and capacity from the same Pod observation,
# including idempotent create and the AlreadyExists race.
pod = core_v1.read_namespaced_pod(_pod_name(sandbox_id), K8S_NAMESPACE)
capacity = _pod_shell_capacity(pod)
bash_capacity = _pod_bash_capacity(pod)
if required_bash_capacity is not None and bash_capacity < required_bash_capacity:
raise HTTPException(status_code=409, detail="Existing sandbox Bash capacity is below the requested value; replacement must be coordinated by the Gateway")
containers = getattr(getattr(pod, "spec", None), "containers", None) or []
init_containers = getattr(getattr(pod, "spec", None), "init_containers", None) or []
sidecar = any(container.name == "lark-cli-broker" for container in containers)
@ -1267,13 +1292,14 @@ def _sandbox_response(
# Terminating Pod; status/get/list callers pass no requirements and read
# the phase as data — raising here would fail the whole fleet listing for
# one terminating Pod (HTTPException escapes their narrower catch clause).
if (required_capacity is not None or required_broker is not None) and status == "Terminating":
if (required_capacity is not None or required_bash_capacity is not None or required_broker is not None) and status == "Terminating":
raise HTTPException(status_code=409, detail="Existing sandbox is still terminating")
return SandboxResponse(
sandbox_id=sandbox_id,
sandbox_url=sandbox_url,
status=status,
max_shell_sessions=capacity,
max_bash_sessions=bash_capacity,
container_id=getattr(metadata, "uid", None),
lark_cli_broker=broker,
)
@ -1318,6 +1344,7 @@ 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
max_bash_sessions = req.max_bash_sessions
_validate_lark_provisioning_request(provision_lark_cli_runtime, provision_lark_cli_broker)
required_broker = provision_lark_cli_broker if provision_lark_cli_runtime or provision_lark_cli_broker else None
@ -1341,6 +1368,7 @@ def create_sandbox(req: CreateSandboxRequest):
sandbox_id,
existing_url,
required_capacity=max_shell_sessions,
required_bash_capacity=max_bash_sessions,
required_broker=required_broker,
)
except (ApiException, RuntimeError) as exc:
@ -1362,6 +1390,7 @@ def create_sandbox(req: CreateSandboxRequest):
provision_lark_cli_runtime=provision_lark_cli_runtime,
provision_lark_cli_broker=provision_lark_cli_broker,
max_shell_sessions=max_shell_sessions,
max_bash_sessions=max_bash_sessions,
),
)
logger.info(f"Created Pod {_pod_name(sandbox_id)}")
@ -1400,6 +1429,7 @@ def create_sandbox(req: CreateSandboxRequest):
sandbox_id,
sandbox_url,
required_capacity=max_shell_sessions,
required_bash_capacity=max_bash_sessions,
required_broker=required_broker,
)
except (ApiException, RuntimeError) as exc: