Skip to content

Commit 49cd4d2

Browse files
authored
feat(cua): expire idle sandbox sessions (#8074)
* feat(cua): expire idle sandbox sessions * fix(cua): simplify idle timeout state
1 parent 116c66b commit 49cd4d2

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

astrbot/core/computer/computer_client.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import asyncio
12
import json
23
import os
34
import shutil
5+
import time
46
import uuid
7+
from dataclasses import dataclass
58
from pathlib import Path
69

710
from astrbot.api import logger
@@ -20,6 +23,70 @@
2023
_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json"
2124

2225

26+
@dataclass(slots=True)
27+
class _CUAIdleState:
28+
expires_at: float
29+
task: asyncio.Task
30+
31+
32+
cua_idle_state: dict[str, _CUAIdleState] = {}
33+
34+
35+
def _get_cua_idle_timeout(config: dict) -> float:
36+
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
37+
value = sandbox_cfg.get("cua_idle_timeout", 0)
38+
try:
39+
timeout = float(value)
40+
except (TypeError, ValueError):
41+
return 0.0
42+
return max(timeout, 0.0)
43+
44+
45+
def _clear_cua_idle_state(session_id: str) -> None:
46+
state = cua_idle_state.pop(session_id, None)
47+
if state is not None and not state.task.done():
48+
state.task.cancel()
49+
50+
51+
def _schedule_cua_idle_cleanup(session_id: str, timeout: float) -> None:
52+
_clear_cua_idle_state(session_id)
53+
if timeout <= 0:
54+
return
55+
expires_at = time.monotonic() + timeout
56+
57+
async def _expire_when_idle() -> None:
58+
try:
59+
remaining = expires_at - time.monotonic()
60+
if remaining > 0:
61+
await asyncio.sleep(remaining)
62+
63+
state = cua_idle_state.get(session_id)
64+
if state is None or state.expires_at != expires_at:
65+
return
66+
67+
booter = session_booter.get(session_id)
68+
if booter is not None:
69+
try:
70+
await booter.shutdown()
71+
except Exception as shutdown_err:
72+
logger.warning(
73+
"[Computer] Failed to shutdown idle CUA sandbox for session %s: %s",
74+
session_id,
75+
shutdown_err,
76+
)
77+
finally:
78+
session_booter.pop(session_id, None)
79+
except asyncio.CancelledError:
80+
raise
81+
finally:
82+
state = cua_idle_state.get(session_id)
83+
if state is not None and state.expires_at == expires_at:
84+
cua_idle_state.pop(session_id, None)
85+
86+
task = asyncio.create_task(_expire_when_idle())
87+
cua_idle_state[session_id] = _CUAIdleState(expires_at=expires_at, task=task)
88+
89+
2390
def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
2491
skills: list[Path] = []
2592
for entry in sorted(skills_root.iterdir()):
@@ -486,6 +553,7 @@ async def get_booter(
486553

487554
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
488555
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
556+
cua_idle_timeout = _get_cua_idle_timeout(config) if booter_type == "cua" else 0.0
489557

490558
if session_id in session_booter:
491559
booter = session_booter[session_id]
@@ -506,6 +574,7 @@ async def get_booter(
506574
session_id,
507575
shutdown_err,
508576
)
577+
_clear_cua_idle_state(session_id)
509578
session_booter.pop(session_id, None)
510579
if session_id not in session_booter:
511580
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
@@ -579,9 +648,12 @@ async def get_booter(
579648
session_id,
580649
shutdown_error,
581650
)
651+
_clear_cua_idle_state(session_id)
582652
raise e
583653

584654
session_booter[session_id] = client
655+
if booter_type == "cua":
656+
_schedule_cua_idle_cleanup(session_id, cua_idle_timeout)
585657
return session_booter[session_id]
586658

587659

astrbot/core/config/default.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
"cua_image": CUA_DEFAULT_CONFIG["image"],
180180
"cua_os_type": CUA_DEFAULT_CONFIG["os_type"],
181181
"cua_ttl": CUA_DEFAULT_CONFIG["ttl"],
182+
"cua_idle_timeout": 0,
182183
"cua_telemetry_enabled": CUA_DEFAULT_CONFIG["telemetry_enabled"],
183184
"cua_local": CUA_DEFAULT_CONFIG["local"],
184185
"cua_api_key": CUA_DEFAULT_CONFIG["api_key"],

tests/unit/test_cua_computer_use.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ def get_config(self, umo: str | None = None):
2020
return self._config
2121

2222

23+
def _clear_cua_session_state(computer_client, session_id: str) -> None:
24+
computer_client.session_booter.pop(session_id, None)
25+
state = getattr(computer_client, "cua_idle_state", {}).pop(session_id, None)
26+
if state is not None and not state.task.done():
27+
state.task.cancel()
28+
29+
2330
class FakeShell:
2431
def __init__(self):
2532
self.commands = []
@@ -267,6 +274,7 @@ def test_cua_default_config_matches_booter_defaults():
267274
assert sandbox_defaults["cua_image"] == CUA_DEFAULT_CONFIG["image"]
268275
assert sandbox_defaults["cua_os_type"] == CUA_DEFAULT_CONFIG["os_type"]
269276
assert sandbox_defaults["cua_ttl"] == CUA_DEFAULT_CONFIG["ttl"]
277+
assert sandbox_defaults["cua_idle_timeout"] == 0
270278
assert (
271279
sandbox_defaults["cua_telemetry_enabled"]
272280
== CUA_DEFAULT_CONFIG["telemetry_enabled"]
@@ -367,6 +375,189 @@ async def fail_sync(booter):
367375
assert "cua-sync-fail" not in computer_client.session_booter
368376

369377

378+
@pytest.mark.asyncio
379+
async def test_cua_idle_timeout_shuts_down_session_proactively(monkeypatch):
380+
from astrbot.core.computer import computer_client
381+
382+
shutdowns = []
383+
384+
class FakeCuaBooter:
385+
def __init__(self, **kwargs):
386+
self.kwargs = kwargs
387+
388+
async def boot(self, session_id: str):
389+
self.session_id = session_id
390+
391+
async def available(self):
392+
return True
393+
394+
async def shutdown(self):
395+
shutdowns.append(self.session_id)
396+
397+
monkeypatch.setattr(
398+
computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0)
399+
)
400+
monkeypatch.setattr(
401+
"astrbot.core.computer.booters.cua.CuaBooter",
402+
FakeCuaBooter,
403+
raising=False,
404+
)
405+
_clear_cua_session_state(computer_client, "cua-idle-expire")
406+
407+
ctx = FakeContext(
408+
{
409+
"provider_settings": {
410+
"computer_use_runtime": "sandbox",
411+
"sandbox": {
412+
"booter": "cua",
413+
"cua_idle_timeout": 0.1,
414+
},
415+
}
416+
}
417+
)
418+
419+
booter = await computer_client.get_booter(ctx, "cua-idle-expire")
420+
await asyncio.sleep(0.2)
421+
422+
assert shutdowns == [booter.session_id]
423+
assert "cua-idle-expire" not in computer_client.session_booter
424+
425+
426+
@pytest.mark.asyncio
427+
async def test_cua_idle_timeout_refreshes_on_reuse(monkeypatch):
428+
from astrbot.core.computer import computer_client
429+
430+
shutdowns = []
431+
432+
class FakeCuaBooter:
433+
def __init__(self, **kwargs):
434+
self.kwargs = kwargs
435+
436+
async def boot(self, session_id: str):
437+
self.session_id = session_id
438+
439+
async def available(self):
440+
return True
441+
442+
async def shutdown(self):
443+
shutdowns.append(self.session_id)
444+
445+
monkeypatch.setattr(
446+
computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0)
447+
)
448+
monkeypatch.setattr(
449+
"astrbot.core.computer.booters.cua.CuaBooter",
450+
FakeCuaBooter,
451+
raising=False,
452+
)
453+
_clear_cua_session_state(computer_client, "cua-idle-refresh")
454+
455+
ctx = FakeContext(
456+
{
457+
"provider_settings": {
458+
"computer_use_runtime": "sandbox",
459+
"sandbox": {
460+
"booter": "cua",
461+
"cua_idle_timeout": 0.2,
462+
},
463+
}
464+
}
465+
)
466+
467+
booter1 = await computer_client.get_booter(ctx, "cua-idle-refresh")
468+
await asyncio.sleep(0.05)
469+
booter2 = await computer_client.get_booter(ctx, "cua-idle-refresh")
470+
await asyncio.sleep(0.05)
471+
472+
assert booter2 is booter1
473+
assert shutdowns == []
474+
475+
await asyncio.sleep(0.25)
476+
477+
assert shutdowns == [booter1.session_id]
478+
assert "cua-idle-refresh" not in computer_client.session_booter
479+
480+
481+
@pytest.mark.asyncio
482+
async def test_cua_idle_timeout_zero_disables_proactive_shutdown(monkeypatch):
483+
from astrbot.core.computer import computer_client
484+
485+
shutdowns = []
486+
487+
class FakeCuaBooter:
488+
def __init__(self, **kwargs):
489+
self.kwargs = kwargs
490+
491+
async def boot(self, session_id: str):
492+
self.session_id = session_id
493+
494+
async def available(self):
495+
return True
496+
497+
async def shutdown(self):
498+
shutdowns.append(self.session_id)
499+
500+
monkeypatch.setattr(
501+
computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0)
502+
)
503+
monkeypatch.setattr(
504+
"astrbot.core.computer.booters.cua.CuaBooter",
505+
FakeCuaBooter,
506+
raising=False,
507+
)
508+
_clear_cua_session_state(computer_client, "cua-idle-disabled")
509+
510+
ctx = FakeContext(
511+
{
512+
"provider_settings": {
513+
"computer_use_runtime": "sandbox",
514+
"sandbox": {
515+
"booter": "cua",
516+
"cua_idle_timeout": 0,
517+
},
518+
}
519+
}
520+
)
521+
522+
await computer_client.get_booter(ctx, "cua-idle-disabled")
523+
await asyncio.sleep(0.05)
524+
525+
assert shutdowns == []
526+
assert "cua-idle-disabled" in computer_client.session_booter
527+
assert "cua-idle-disabled" not in computer_client.cua_idle_state
528+
529+
530+
@pytest.mark.asyncio
531+
async def test_non_cua_booter_does_not_schedule_idle_cleanup(monkeypatch):
532+
from astrbot.core.computer import computer_client
533+
534+
class FakeShipyardBooter:
535+
async def available(self):
536+
return True
537+
538+
_clear_cua_session_state(computer_client, "shipyard-session")
539+
computer_client.session_booter["shipyard-session"] = FakeShipyardBooter()
540+
541+
ctx = FakeContext(
542+
{
543+
"provider_settings": {
544+
"computer_use_runtime": "sandbox",
545+
"sandbox": {
546+
"booter": "shipyard",
547+
"shipyard_endpoint": "http://localhost:8080",
548+
"shipyard_access_token": "token",
549+
"cua_idle_timeout": 0.01,
550+
},
551+
}
552+
}
553+
)
554+
555+
booter = await computer_client.get_booter(ctx, "shipyard-session")
556+
557+
assert isinstance(booter, FakeShipyardBooter)
558+
assert "shipyard-session" not in computer_client.cua_idle_state
559+
560+
370561
@pytest.mark.asyncio
371562
async def test_cua_components_map_sdk_results(tmp_path):
372563
from astrbot.core.computer.booters.cua import (

0 commit comments

Comments
 (0)