From c881042e650a802445f55ad5dc500ac2f8bffa7a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 01:08:39 +0000 Subject: [PATCH 1/8] docs: update contributors list --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0bc150b6e..08485afd7 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,13 @@ Thanks to the following developers for their contributions and efforts to make t Joyway78 + + + icycrystal4 +
+ Icycrystal4 +
+ parabala @@ -309,13 +316,6 @@ Thanks to the following developers for their contributions and efforts to make t Moqimoqidea - - - icycrystal4 -
- Icycrystal4 -
- 2561056571 @@ -441,7 +441,7 @@ Thanks to the following developers for their contributions and efforts to make t code-wangdi
- code-wangdi + Code-wangdi
From 945f53dfa0bf044b3f1f2b47ff91397aa453eef1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 01:08:41 +0000 Subject: [PATCH 2/8] docs: update contributors list (zh) --- README_zh.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README_zh.md b/README_zh.md index 22f66ae7f..42d93114f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -296,6 +296,13 @@ docker compose up -d # 启动 Joyway78 + + + icycrystal4 +
+ Icycrystal4 +
+ parabala @@ -310,13 +317,6 @@ docker compose up -d # 启动 Moqimoqidea - - - icycrystal4 -
- Icycrystal4 -
- 2561056571 @@ -442,7 +442,7 @@ docker compose up -d # 启动 code-wangdi
- code-wangdi + Code-wangdi
From 80c096237f176ab4bbdef1a8d92ca4eb9d83d416 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 01:12:50 +0000 Subject: [PATCH 3/8] docs: update contributors list --- README.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 39dbf7bf5..9aad0be95 100644 --- a/README.md +++ b/README.md @@ -238,13 +238,6 @@ Thanks to the following developers for their contributions and efforts to make t Axb - - - Micro66 -
- MicroLee -
- feifei325 @@ -253,10 +246,10 @@ Thanks to the following developers for their contributions and efforts to make t - - cc-yafei + + Micro66
- YaFei Liu + MicroLee
@@ -266,6 +259,13 @@ Thanks to the following developers for their contributions and efforts to make t FicoHu + + + cc-yafei +
+ YaFei Liu +
+ kissghosts @@ -280,14 +280,21 @@ Thanks to the following developers for their contributions and efforts to make t Johnny0120 + + + parabala +
+ Parabala +
+ + yixiangxx
Yi Xiang
- - + joyway1978 @@ -302,13 +309,6 @@ Thanks to the following developers for their contributions and efforts to make t Icycrystal4 - - - parabala -
- Parabala -
- moqimoqidea From 84baf74ccf82a8717153bfb9c161af322ecaaca7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 01:12:51 +0000 Subject: [PATCH 4/8] docs: update contributors list (zh) --- README_zh.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README_zh.md b/README_zh.md index c8ee1c572..d72bac580 100644 --- a/README_zh.md +++ b/README_zh.md @@ -239,13 +239,6 @@ docker compose up -d # 启动 Axb - - - Micro66 -
- MicroLee -
- feifei325 @@ -254,10 +247,10 @@ docker compose up -d # 启动 - - cc-yafei + + Micro66
- YaFei Liu + MicroLee
@@ -267,6 +260,13 @@ docker compose up -d # 启动 FicoHu + + + cc-yafei +
+ YaFei Liu +
+ kissghosts @@ -281,14 +281,21 @@ docker compose up -d # 启动 Johnny0120 + + + parabala +
+ Parabala +
+ + yixiangxx
Yi Xiang
- - + joyway1978 @@ -303,13 +310,6 @@ docker compose up -d # 启动 Icycrystal4 - - - parabala -
- Parabala -
- moqimoqidea From 0ce6adc88cf59c4f98020857e4bde9b905e0f2e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 19 Apr 2026 01:15:51 +0000 Subject: [PATCH 5/8] docs: update contributors list --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7647804ba..0dd0684b0 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,13 @@ Thanks to the following developers for their contributions and efforts to make t + + + icycrystal4 +
+ Icycrystal4 +
+ yixiangxx @@ -302,13 +309,6 @@ Thanks to the following developers for their contributions and efforts to make t Joyway78 - - - icycrystal4 -
- Icycrystal4 -
- moqimoqidea @@ -444,21 +444,28 @@ Thanks to the following developers for their contributions and efforts to make t Andrewzq777 + + + cocowh +
+ Birch +
+ graindt
Graindt
- + + qingchengliu
Qingcheng
- - + salt-hai From bbed9ce4fb594a9c0286bda60451cd75056db4cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 19 Apr 2026 01:15:53 +0000 Subject: [PATCH 6/8] docs: update contributors list (zh) --- README_zh.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README_zh.md b/README_zh.md index 8d45932db..9804e51a9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -289,6 +289,13 @@ docker compose up -d # 启动 + + + icycrystal4 +
+ Icycrystal4 +
+ yixiangxx @@ -303,13 +310,6 @@ docker compose up -d # 启动 Joyway78 - - - icycrystal4 -
- Icycrystal4 -
- moqimoqidea @@ -445,21 +445,28 @@ docker compose up -d # 启动 Andrewzq777 + + + cocowh +
+ Birch +
+ graindt
Graindt
- + + qingchengliu
Qingcheng
- - + salt-hai From d85b483e3b4d75cd809e96602686b30090301952 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Wed, 22 Apr 2026 18:14:39 +0800 Subject: [PATCH 7/8] fix(executor_manager): align sandbox expiry gc --- executor_manager/common/config.py | 3 +- executor_manager/services/sandbox/manager.py | 25 +++++-- .../services/sandbox/repository.py | 20 +++--- .../tests/services/test_sandbox_manager.py | 67 +++++++++++++++++-- 4 files changed, 96 insertions(+), 19 deletions(-) diff --git a/executor_manager/common/config.py b/executor_manager/common/config.py index 3c13d7337..f0de4d01d 100644 --- a/executor_manager/common/config.py +++ b/executor_manager/common/config.py @@ -66,7 +66,8 @@ class TimeoutConfig: http_execution_request: float = 30.0 http_container_wait: float = 5.0 - # Redis TTL + # Redis data retention window for sandbox state + # This is not the sandbox execution timeout; sandbox expiry uses expires_at. redis_ttl: int = 86400 # 24 hours # Session hash TTL must be longer than redis_ttl to ensure GC can load sandbox data # Formula: redis_ttl + GC_INTERVAL + buffer (default: 86400 + 3600 + 3600 = 93600) diff --git a/executor_manager/services/sandbox/manager.py b/executor_manager/services/sandbox/manager.py index 58f18a283..a764da3a2 100644 --- a/executor_manager/services/sandbox/manager.py +++ b/executor_manager/services/sandbox/manager.py @@ -977,8 +977,9 @@ async def _terminate_expired_sandbox(self, task_id_str: str) -> None: async def _collect_expired_sandboxes(self) -> None: """Terminate expired sandboxes. - Uses repository to efficiently find sandboxes whose last_activity_timestamp - is older than the configured TTL. + Scans active sandboxes and terminates those whose expires_at timestamp + has passed. This matches the sandbox API contract, which exposes + expires_at as the lifecycle timeout source of truth. """ lock = get_distributed_lock() if not lock.acquire("sandbox_gc", expire_seconds=300): @@ -989,9 +990,23 @@ async def _collect_expired_sandboxes(self) -> None: try: logger.info("[SandboxManager] Running sandbox GC...") - expired_task_ids = self._repository.get_expired_sandbox_ids( - self._config.timeout.redis_ttl - ) + active_task_ids = self._repository.get_active_sandbox_ids() + if not active_task_ids: + logger.info("[SandboxManager] No active sandboxes found") + return + + expired_task_ids = [] + for task_id_str in active_task_ids: + sandbox = self._repository.load_sandbox(task_id_str) + if sandbox is None: + self._repository.remove_from_active_set(task_id_str) + logger.debug( + f"[SandboxManager] Cleaned orphaned ZSet entry: {task_id_str}" + ) + continue + + if sandbox.is_expired(): + expired_task_ids.append(task_id_str) if not expired_task_ids: logger.info("[SandboxManager] No expired sandboxes found") diff --git a/executor_manager/services/sandbox/repository.py b/executor_manager/services/sandbox/repository.py index ae503c92d..10d7ec700 100644 --- a/executor_manager/services/sandbox/repository.py +++ b/executor_manager/services/sandbox/repository.py @@ -13,13 +13,13 @@ - {subtask_id} fields: Execution data JSON - TTL: session_hash_ttl (longer than redis_ttl to ensure GC can load data) - Active Sandboxes ZSet: wegent-sandbox:active (score = last_activity timestamp) - - GC uses redis_ttl to determine which sandboxes are expired + - Stores recent activity timestamps and supports orphan cleanup helpers GC Design: - GC runs every GC_INTERVAL (default 3600s) to clean up expired sandboxes -- Sandboxes are considered expired if last_activity > redis_ttl (default 86400s) +- Sandbox expiration is determined by sandbox.expires_at in SandboxManager - Session hash has TTL = session_hash_ttl = redis_ttl + GC_INTERVAL + buffer -- This ensures GC can still load sandbox data even when it's marked as expired +- This ensures GC can still load sandbox data long enough to terminate expired sandboxes """ import json @@ -109,6 +109,9 @@ def save_sandbox(self, sandbox: Sandbox) -> bool: "status": sandbox.status.value, "error_message": sandbox.error_message, "created_at": sandbox.created_at, + "started_at": sandbox.started_at, + "last_activity_at": sandbox.last_activity_at, + "expires_at": sandbox.expires_at, "shell_type": sandbox.shell_type, "user_id": sandbox.user_id, "user_name": sandbox.user_name, @@ -333,16 +336,17 @@ async def get_active_sandbox_ids_async(self) -> List[str]: return [] def get_expired_sandbox_ids(self, max_age_seconds: int) -> List[str]: - """Get sandbox IDs that have been inactive for longer than max_age. + """Get sandbox IDs whose last-activity score is older than max_age. - Uses ZRANGEBYSCORE to efficiently find sandboxes whose last_activity_timestamp - is older than the cutoff time. + This helper is based on ZSet activity timestamps. SandboxManager GC now + expires sandboxes using sandbox.expires_at, but this method remains useful + for diagnostics or legacy maintenance paths that need activity-age queries. Args: - max_age_seconds: Maximum age in seconds (e.g., 86400 for 24 hours) + max_age_seconds: Maximum activity age in seconds Returns: - List of expired sandbox IDs + List of sandbox IDs whose activity score is older than the cutoff """ if self.redis_client is None: return [] diff --git a/executor_manager/tests/services/test_sandbox_manager.py b/executor_manager/tests/services/test_sandbox_manager.py index ff0f03ae9..410619528 100644 --- a/executor_manager/tests/services/test_sandbox_manager.py +++ b/executor_manager/tests/services/test_sandbox_manager.py @@ -580,6 +580,36 @@ def test_save_sandbox_success( mock_redis_client.expire.assert_called() mock_redis_client.zadd.assert_called() + def test_save_sandbox_round_trip_preserves_timing_fields( + self, sandbox_manager_with_mock_redis, mock_redis_client, sample_sandbox + ): + """Test save/load preserves started_at, last_activity_at, and expires_at.""" + manager = sandbox_manager_with_mock_redis + + result = manager._repository.save_sandbox(sample_sandbox) + + assert result is True + + hset_args = mock_redis_client.hset.call_args[0] + saved_hash_key = hset_args[0] + saved_field = hset_args[1] + saved_payload = hset_args[2] + saved_data = json.loads(saved_payload) + + assert saved_data["started_at"] == sample_sandbox.started_at + assert saved_data["last_activity_at"] == sample_sandbox.last_activity_at + assert saved_data["expires_at"] == sample_sandbox.expires_at + + mock_redis_client.hget.return_value = saved_payload + loaded_sandbox = manager._repository.load_sandbox(sample_sandbox.sandbox_id) + + assert loaded_sandbox is not None + assert saved_hash_key.endswith(sample_sandbox.sandbox_id) + assert saved_field == "__sandbox__" + assert loaded_sandbox.started_at == sample_sandbox.started_at + assert loaded_sandbox.last_activity_at == sample_sandbox.last_activity_at + assert loaded_sandbox.expires_at == sample_sandbox.expires_at + def test_save_sandbox_missing_task_id( self, sandbox_manager_with_mock_redis, mock_redis_client ): @@ -961,13 +991,13 @@ async def test_collect_expired_sandboxes_terminates_old( self, sandbox_manager_with_mock_redis, mock_redis_client, - sample_sandbox_redis_data, mocker, sample_sandbox, ): - """Test terminates sandboxes older than 24 hours.""" + """Test terminates active sandboxes whose expires_at has passed.""" manager = sandbox_manager_with_mock_redis - mock_redis_client.zrangebyscore.return_value = ["12345"] + mock_redis_client.zrange.return_value = ["12345"] + sample_sandbox.expires_at = time.time() - 60 # Mock repository.load_sandbox to return a sandbox mocker.patch.object( @@ -990,9 +1020,9 @@ async def test_collect_expired_sandboxes_terminates_old( async def test_collect_expired_sandboxes_cleans_orphaned( self, sandbox_manager_with_mock_redis, mock_redis_client, mocker ): - """Test cleans orphaned ZSet entries.""" + """Test cleans orphaned active set entries.""" manager = sandbox_manager_with_mock_redis - mock_redis_client.zrangebyscore.return_value = ["orphaned-id"] + mock_redis_client.zrange.return_value = ["orphaned-id"] mock_redis_client.hget.return_value = None # No sandbox data await manager._collect_expired_sandboxes() @@ -1001,6 +1031,33 @@ async def test_collect_expired_sandboxes_cleans_orphaned( "wegent-sandbox:active", "orphaned-id" ) + @pytest.mark.asyncio + async def test_collect_expired_sandboxes_skips_unexpired( + self, + sandbox_manager_with_mock_redis, + mock_redis_client, + mocker, + sample_sandbox, + ): + """Test does not terminate active sandboxes before expires_at.""" + manager = sandbox_manager_with_mock_redis + mock_redis_client.zrange.return_value = ["12345"] + sample_sandbox.expires_at = time.time() + 300 + + mocker.patch.object( + manager._repository, "load_sandbox", return_value=sample_sandbox + ) + mock_terminate = mocker.patch.object( + manager, + "terminate_sandbox", + new_callable=AsyncMock, + return_value=(True, "Terminated"), + ) + + await manager._collect_expired_sandboxes() + + mock_terminate.assert_not_called() + # ----- Scheduler Integration Tests ----- @pytest.mark.asyncio From 51ab0c4921a22c40d7cf71189fb51469e8f7d774 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Wed, 22 Apr 2026 18:40:55 +0800 Subject: [PATCH 8/8] fix(executor_manager): clean sandboxes after inactivity --- executor_manager/common/config.py | 3 +++ executor_manager/routers/sandbox.py | 9 +++++---- executor_manager/schemas/sandbox.py | 14 ++++++++------ executor_manager/services/sandbox/manager.py | 10 ++++++---- executor_manager/services/sandbox/repository.py | 2 +- .../tests/services/test_sandbox_manager.py | 10 ++++++---- 6 files changed, 29 insertions(+), 19 deletions(-) diff --git a/executor_manager/common/config.py b/executor_manager/common/config.py index f0de4d01d..71651fa6e 100644 --- a/executor_manager/common/config.py +++ b/executor_manager/common/config.py @@ -47,6 +47,9 @@ class TimeoutConfig: # Sandbox timeouts sandbox_default: int = 1800 # 30 minutes + sandbox_inactive_timeout: int = field( + default_factory=lambda: int(os.getenv("SANDBOX_INACTIVE_TIMEOUT", "7200")) + ) execution_default: int = 600 # 10 minutes container_ready: int = field( default_factory=lambda: int(os.getenv("CONTAINER_READY_TIMEOUT", "20")) diff --git a/executor_manager/routers/sandbox.py b/executor_manager/routers/sandbox.py index 50f00abf5..504e37605 100644 --- a/executor_manager/routers/sandbox.py +++ b/executor_manager/routers/sandbox.py @@ -57,8 +57,9 @@ async def create_sandbox(request: CreateSandboxRequest, http_request: Request): """Create a new sandbox. Creates an isolated execution environment (Docker container) that can - run multiple executions. The sandbox will automatically terminate - after the specified timeout unless kept alive. + run multiple executions. The sandbox remains available for new executions + until its active timeout expires, and background cleanup removes idle + sandboxes after the inactivity window. Args: request: Sandbox creation parameters @@ -205,8 +206,8 @@ async def keep_alive( ): """Extend sandbox timeout. - Adds additional time to the sandbox expiration. The sandbox will - automatically terminate after the new timeout unless kept alive again. + Adds additional time to the sandbox active timeout. Idle sandbox cleanup + is still controlled by the background inactivity window. Args: sandbox_id: Unique sandbox identifier (internally uses task_id) diff --git a/executor_manager/schemas/sandbox.py b/executor_manager/schemas/sandbox.py index 64970ce48..c5faed82c 100644 --- a/executor_manager/schemas/sandbox.py +++ b/executor_manager/schemas/sandbox.py @@ -66,7 +66,7 @@ class CreateSandboxResponse(BaseModel): container_name: Docker container name shell_type: Execution environment type created_at: Timestamp when sandbox was created - expires_at: Timestamp when sandbox will auto-terminate + expires_at: Timestamp when the active timeout window ends message: Optional status message """ @@ -79,7 +79,9 @@ class CreateSandboxResponse(BaseModel): description="Executor namespace when available", ) created_at: float = Field(..., description="Creation timestamp") - expires_at: Optional[float] = Field(None, description="Expiration timestamp") + expires_at: Optional[float] = Field( + None, description="Active-timeout expiration timestamp" + ) message: Optional[str] = Field(None, description="Status message") @@ -97,9 +99,9 @@ class SandboxStatusResponse(BaseModel): created_at: Creation timestamp started_at: When sandbox became running last_activity_at: Last activity timestamp - expires_at: Expiration timestamp + expires_at: Active-timeout expiration timestamp uptime: Sandbox uptime in seconds - time_remaining: Seconds until expiration + time_remaining: Seconds until active-timeout expiration execution_count: Number of executions in this sandbox error_message: Error message if failed metadata: Additional metadata @@ -157,8 +159,8 @@ class KeepAliveResponse(BaseModel): Attributes: sandbox_id: Unique sandbox identifier - expires_at: New expiration timestamp - time_remaining: Seconds until expiration + expires_at: New active-timeout expiration timestamp + time_remaining: Seconds until active-timeout expiration message: Status message """ diff --git a/executor_manager/services/sandbox/manager.py b/executor_manager/services/sandbox/manager.py index a764da3a2..15c21e8a8 100644 --- a/executor_manager/services/sandbox/manager.py +++ b/executor_manager/services/sandbox/manager.py @@ -977,9 +977,8 @@ async def _terminate_expired_sandbox(self, task_id_str: str) -> None: async def _collect_expired_sandboxes(self) -> None: """Terminate expired sandboxes. - Scans active sandboxes and terminates those whose expires_at timestamp - has passed. This matches the sandbox API contract, which exposes - expires_at as the lifecycle timeout source of truth. + Scans active sandboxes and terminates those that have been idle longer + than the configured inactivity timeout. """ lock = get_distributed_lock() if not lock.acquire("sandbox_gc", expire_seconds=300): @@ -995,6 +994,8 @@ async def _collect_expired_sandboxes(self) -> None: logger.info("[SandboxManager] No active sandboxes found") return + now = time.time() + inactivity_timeout = self._config.timeout.sandbox_inactive_timeout expired_task_ids = [] for task_id_str in active_task_ids: sandbox = self._repository.load_sandbox(task_id_str) @@ -1005,7 +1006,8 @@ async def _collect_expired_sandboxes(self) -> None: ) continue - if sandbox.is_expired(): + idle_seconds = now - sandbox.last_activity_at + if idle_seconds >= inactivity_timeout: expired_task_ids.append(task_id_str) if not expired_task_ids: diff --git a/executor_manager/services/sandbox/repository.py b/executor_manager/services/sandbox/repository.py index 10d7ec700..02f5f2455 100644 --- a/executor_manager/services/sandbox/repository.py +++ b/executor_manager/services/sandbox/repository.py @@ -17,7 +17,7 @@ GC Design: - GC runs every GC_INTERVAL (default 3600s) to clean up expired sandboxes -- Sandbox expiration is determined by sandbox.expires_at in SandboxManager +- Sandbox expiration is determined by sandbox.last_activity_at in SandboxManager - Session hash has TTL = session_hash_ttl = redis_ttl + GC_INTERVAL + buffer - This ensures GC can still load sandbox data long enough to terminate expired sandboxes """ diff --git a/executor_manager/tests/services/test_sandbox_manager.py b/executor_manager/tests/services/test_sandbox_manager.py index 410619528..7234f80de 100644 --- a/executor_manager/tests/services/test_sandbox_manager.py +++ b/executor_manager/tests/services/test_sandbox_manager.py @@ -994,10 +994,11 @@ async def test_collect_expired_sandboxes_terminates_old( mocker, sample_sandbox, ): - """Test terminates active sandboxes whose expires_at has passed.""" + """Test terminates sandboxes idle for more than two hours.""" manager = sandbox_manager_with_mock_redis mock_redis_client.zrange.return_value = ["12345"] - sample_sandbox.expires_at = time.time() - 60 + sample_sandbox.last_activity_at = time.time() - (2 * 3600) - 60 + sample_sandbox.expires_at = time.time() + 3600 # Mock repository.load_sandbox to return a sandbox mocker.patch.object( @@ -1039,10 +1040,11 @@ async def test_collect_expired_sandboxes_skips_unexpired( mocker, sample_sandbox, ): - """Test does not terminate active sandboxes before expires_at.""" + """Test keeps sandboxes with recent activity even if expires_at is in the past.""" manager = sandbox_manager_with_mock_redis mock_redis_client.zrange.return_value = ["12345"] - sample_sandbox.expires_at = time.time() + 300 + sample_sandbox.last_activity_at = time.time() - 300 + sample_sandbox.expires_at = time.time() - 60 mocker.patch.object( manager._repository, "load_sandbox", return_value=sample_sandbox