Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions backend/package/yuxi/agents/backends/sandbox/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ def __init__(self):
self._connections: dict[str, SandboxConnection] = {}
self._last_touch_at: dict[str, float] = {}
self._touch_interval_seconds = int(os.getenv("SANDBOX_KEEPALIVE_INTERVAL_SECONDS") or 30)
# 主动 keepalive:编排型主智能体可能长时间不执行沙盒命令,惰性 touch 会导致
# 沙盒 idle 超时被 provisioner 清理,随后 create_if_missing=False 无法重建。
self._stop_event = threading.Event()
self._keepalive_thread: threading.Thread | None = None
if self._touch_interval_seconds > 0:
self._keepalive_thread = threading.Thread(
target=self._keepalive_loop,
name="sandbox-keepalive",
daemon=True,
)
self._keepalive_thread.start()

def _thread_lock(self, cache_key: str) -> threading.Lock:
with self._lock:
Expand Down Expand Up @@ -165,6 +176,39 @@ def _touch_if_needed(self, connection: SandboxConnection) -> bool:
connection.generation = record.generation
return True

def _keepalive_touch(self, connection: SandboxConnection) -> bool:
"""仅续命:刷新 provisioner 的活动时间戳,返回沙盒是否仍存活。"""
is_alive = self._client.touch(connection.sandbox_id)
self._last_touch_at[connection.cache_key] = time.time()
return is_alive

def _keepalive_tick(self) -> None:
"""单次续命:touch 所有到期活跃沙盒,移除已清理的连接。"""
with self._lock:
cache_keys = list(self._connections.keys())
for cache_key in cache_keys:
connection = self._connections.get(cache_key)
if connection is None or not self._should_touch(cache_key):
continue
lock = self._thread_lock(cache_key)
with lock:
current = self._connections.get(cache_key)
if current is None:
continue
try:
if not self._keepalive_touch(current):
with self._lock:
self._connections.pop(cache_key, None)
self._last_touch_at.pop(cache_key, None)
except Exception: # noqa: BLE001
# touch 网络抖动保守保留连接,下次 get() 再收敛。
pass

def _keepalive_loop(self) -> None:
"""后台线程:定期续命活跃沙盒,避免主智能体编排期(长时间不执行命令)触发 idle 回收。"""
while not self._stop_event.wait(self._touch_interval_seconds):
self._keepalive_tick()

def get(
self,
thread_id: str,
Expand Down Expand Up @@ -259,6 +303,7 @@ def release(
self._last_touch_at.pop(cache_key, None)

def shutdown(self) -> None:
self._stop_event.set()
with self._lock:
connections = list(self._connections.values())
self._connections.clear()
Expand Down
50 changes: 50 additions & 0 deletions backend/test/unit/backends/test_sandbox_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -1689,3 +1689,53 @@ def test_workdir_paths_are_workspace_relative_and_reject_symlinks(monkeypatch, t
(projects / file_id).write_text("file", encoding="utf-8")
with pytest.raises(ValueError, match="符号链接或非目录组件"):
paths.user_workdir_host_dir("user-1", f"projects/{file_id}")


def test_sandbox_provider_keepalive_touch_reports_liveness_and_updates_timestamp():
touched: list[str] = []
provider = _make_provider(SimpleNamespace(touch=lambda sandbox_id: touched.append(sandbox_id) or True))
cache_key = "user-1::thread-1"
connection = SimpleNamespace(sandbox_id="sandbox-1", cache_key=cache_key)
provider._connections[cache_key] = connection
provider._last_touch_at[cache_key] = 0.0

assert provider._keepalive_touch(connection) is True

assert touched == ["sandbox-1"]
assert provider._last_touch_at[cache_key] > 0.0


def test_sandbox_provider_keepalive_tick_removes_dead_sandbox():
provider = _make_provider(SimpleNamespace(touch=lambda _sandbox_id: False))
cache_key = "user-1::thread-1"
provider._connections[cache_key] = SimpleNamespace(sandbox_id="sandbox-1", cache_key=cache_key)
provider._last_touch_at[cache_key] = 0.0

provider._keepalive_tick()

assert cache_key not in provider._connections
assert cache_key not in provider._last_touch_at


def test_sandbox_provider_keepalive_tick_keeps_alive_sandbox():
provider = _make_provider(SimpleNamespace(touch=lambda _sandbox_id: True))
cache_key = "user-1::thread-1"
connection = SimpleNamespace(sandbox_id="sandbox-1", cache_key=cache_key)
provider._connections[cache_key] = connection
provider._last_touch_at[cache_key] = 0.0

provider._keepalive_tick()

assert provider._connections[cache_key] is connection
assert provider._last_touch_at[cache_key] > 0.0


def test_sandbox_provider_keepalive_tick_skips_fresh_sandbox():
provider = _make_provider(SimpleNamespace(touch=lambda _sandbox_id: pytest.fail("fresh sandbox must not be touched")))
cache_key = "user-1::thread-1"
provider._connections[cache_key] = SimpleNamespace(sandbox_id="sandbox-1", cache_key=cache_key)
provider._last_touch_at[cache_key] = 10**12 # 远晚于当前时刻,_should_touch 应返回 False

provider._keepalive_tick()

assert cache_key in provider._connections
Loading