Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ab4d3f8
feat: add workspace archive and recovery for Pod deletion
Mar 25, 2026
d4f8860
fix(backend): add missing minio dependency
Mar 25, 2026
44fa8d4
feat(admin): add created_at field to device monitor list (#846)
FicoHu Mar 25, 2026
6f782a7
perf: use async heartbeat (#849)
qdaxb Mar 25, 2026
862506c
fix(chat_shell): prevent orphaned tool call IDs in group chat history…
moqimoqidea Mar 26, 2026
fdae4bc
fix: 在里面智能体的创建人和机器人的创建人不是一个人时会报错 (#853)
Micro66 Mar 26, 2026
8e04857
fix: fail fast on invalid tool call IDs across chat protocols (#854)
moqimoqidea Mar 26, 2026
ae1ba75
fix: 订阅任务执行间隔调整 & trace http接口的匹配规则调整 (#855)
Micro66 Mar 26, 2026
f089d1d
fix: sanitize PyInstaller LD_LIBRARY_PATH for child processes (#859)
icycrystal4 Mar 26, 2026
1909a61
refactor(rag): move internal retrieve routing into backend (#847)
kissghosts Mar 26, 2026
0ff7699
frontend: show 'indexing' status for documents pending indexing (#858)
parabala Mar 26, 2026
69fafa6
fix: 修复订阅器中脏数据导致的无法展示的问题 (#860)
Micro66 Mar 26, 2026
920fec8
feat(backend): add knowledge base RAG support to /v1/responses API (#…
parabala Mar 26, 2026
a6a2eb6
fix(chat_shell): use model_type for provider detection instead of mod…
moqimoqidea Mar 26, 2026
c112d02
ix: claude的子代理模式不可用修复 (#862)
Micro66 Mar 26, 2026
79a2646
feat(inbox): implement work queue and message forwarding system (#851)
qdaxb Mar 26, 2026
3cca7a9
Wegent/work queue and message forwarding (#863)
qdaxb Mar 26, 2026
704cb10
fix: fix column default value
qdaxb Mar 26, 2026
e40bc18
fix: fix forward message click
qdaxb Mar 26, 2026
e4fb230
feat: remove unused area
qdaxb Mar 26, 2026
0a1657c
feat: add inbox default message
qdaxb Mar 26, 2026
0b8b5e2
fix: 修复脏数据导致的订阅任务无法执行的问题 (#865)
Micro66 Mar 27, 2026
47410fc
fix(browser): harden browser-tool linux recovery (#867)
icycrystal4 Mar 27, 2026
751fd04
fix: 修复无法使用自定义镜像以及自定义镜像无法校验的问题 (#866)
Micro66 Mar 27, 2026
58e97d7
fix: fix welcome timezone (#868)
qdaxb Mar 27, 2026
ab7fad3
docs: update inbox docs (#869)
qdaxb Mar 27, 2026
d243063
fix: 修复单测 (#870)
Micro66 Mar 27, 2026
88f2687
fix: fix page error (#871)
qdaxb Mar 27, 2026
c4536ff
fix: data-roleid="api-key-create-button" not in html attr (#873)
flyhope Mar 27, 2026
3d22ff7
fix: 修复停止按钮偶发变成发送的问题 & 修复失败的消息会丢失的问题 (#876)
Micro66 Mar 30, 2026
5315a10
fix: 修复“skill not found” (#877)
Micro66 Mar 30, 2026
a2c6669
chore(executor): upgrade claude-agent-sdk from 0.1.31 to 0.1.51 (#880)
icycrystal4 Mar 30, 2026
5215598
feat(frontend): add HTML attachment preview and share link generation…
johnny0120 Mar 30, 2026
cdfb5f5
fix(knowledge): harden document indexing state (#875)
kissghosts Mar 30, 2026
f56c024
docs: update contributors list (zh) (#879)
github-actions[bot] Mar 30, 2026
dc8a089
docs: update contributors list (#878)
github-actions[bot] Mar 30, 2026
75a9280
feat: 支持skill认证 (#882)
Apr 1, 2026
17b1b99
Feature/knowledge document read (#888)
kissghosts Mar 31, 2026
7bd3b52
refactor(web-scraper): add configuration for specific sites that requ…
parabala Mar 31, 2026
25298c2
Merge branch 'main' into feat/workspace-archive-recovery
Apr 1, 2026
facb938
refactor(backend): replace auto-cleanup with archive expiration check
Apr 1, 2026
102abbc
feat: 添加恢复行为测试和脚本路径修正
Apr 1, 2026
5edb79d
fix: 使用 timezone.utc 替换 UTC 以确保时区正确
Apr 1, 2026
2dbaffc
test(backend): fix workspace recovery test patches
Apr 1, 2026
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
7 changes: 7 additions & 0 deletions backend/app/core/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
include=[
"app.tasks.subscription_tasks",
"app.tasks.knowledge_tasks",
"app.tasks.archive_tasks",
],
)

Expand Down Expand Up @@ -73,6 +74,12 @@
"task": "app.tasks.subscription_tasks.check_due_subscriptions",
"schedule": float(settings.FLOW_SCHEDULER_INTERVAL_SECONDS),
},
# Cleanup expired workspace archives daily at 3:00 AM UTC
"cleanup-expired-archives": {
"task": "app.tasks.archive_tasks.cleanup_expired_archives",
"schedule": 86400.0, # 24 hours in seconds
"options": {"queue": settings.CELERY_TASK_DEFAULT_QUEUE},
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check the current state of the file and its imports
head -100 backend/app/core/celery_app.py | cat -n

Repository: wecode-ai/Wegent

Length of output: 4481


🏁 Script executed:

# Check if crontab is already imported
rg "from celery" backend/app/core/celery_app.py -A 2

Repository: wecode-ai/Wegent

Length of output: 194


🏁 Script executed:

# Check for other Celery Beat schedules in the file to understand patterns
rg "schedule.*:" backend/app/core/celery_app.py -B 1 -A 2

Repository: wecode-ai/Wegent

Length of output: 654


🌐 Web query:

Celery Beat schedule relative interval 86400 vs crontab fixed time execution

💡 Result:

In Celery Beat, 'relative interval 86400' refers to using an interval schedule of 86400 seconds (24 hours), while 'crontab fixed time execution' uses a crontab schedule for calendar-based timing like daily at a specific hour. Key differences: - Interval schedule (e.g., 'schedule': 86400.0): Executes every 86400 seconds after the previous execution (or Beat start for first run). Timing drifts relative to wall-clock time if tasks overrun or Beat restarts mid-cycle. Default behavior is non-relative: next run = last_run_at + 86400. - Crontab schedule (e.g., 'schedule': crontab(hour=0, minute=0)): Executes at fixed wall-clock times matching the cron pattern, like midnight every day. Ignores last_run_at; checks if current time matches the pattern. No drift; always calendar-aligned. Relative option (relative=True in ScheduleEntry or schedule): For timedelta/interval schedules only, rounds next run time to the interval's resolution (e.g., nearest day for ~86400s), making it more clock-aligned like "every 24 hours from epoch" rather than strictly from last run. Not applicable to crontab. 86400 interval approximates daily but drifts (e.g., restarts shift cycle), while crontab ensures exact daily fixed time. Examples: app.conf.beat_schedule = { 'daily-interval': { 'task': 'tasks.daily', 'schedule': 86400.0, # Every 24h from last run/Beat start }, 'daily-crontab': { 'task': 'tasks.daily', 'schedule': crontab(hour=0, minute=0), # Midnight daily } }

Citations:


Replace relative 24-hour interval with crontab to ensure fixed execution time.

The 86400.0 schedule is a relative interval that drifts on Beat restarts. With current setup, the task will not consistently execute at 3:00 AM UTC as the comment states—it will shift each time Beat restarts.

Use crontab(hour=3, minute=0) for fixed wall-clock execution. Also add the import: from celery.schedules import crontab

Proposed fix
 from celery import Celery
+from celery.schedules import crontab
 from celery.signals import after_setup_logger, after_setup_task_logger
 
         # Cleanup expired workspace archives daily at 3:00 AM UTC
         "cleanup-expired-archives": {
             "task": "app.tasks.archive_tasks.cleanup_expired_archives",
-            "schedule": 86400.0,  # 24 hours in seconds
+            "schedule": crontab(hour=3, minute=0),
             "options": {"queue": settings.CELERY_TASK_DEFAULT_QUEUE},
         },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Cleanup expired workspace archives daily at 3:00 AM UTC
"cleanup-expired-archives": {
"task": "app.tasks.archive_tasks.cleanup_expired_archives",
"schedule": 86400.0, # 24 hours in seconds
"options": {"queue": settings.CELERY_TASK_DEFAULT_QUEUE},
},
from celery import Celery
from celery.schedules import crontab
from celery.signals import after_setup_logger, after_setup_task_logger
Suggested change
# Cleanup expired workspace archives daily at 3:00 AM UTC
"cleanup-expired-archives": {
"task": "app.tasks.archive_tasks.cleanup_expired_archives",
"schedule": 86400.0, # 24 hours in seconds
"options": {"queue": settings.CELERY_TASK_DEFAULT_QUEUE},
},
# Cleanup expired workspace archives daily at 3:00 AM UTC
"cleanup-expired-archives": {
"task": "app.tasks.archive_tasks.cleanup_expired_archives",
"schedule": crontab(hour=3, minute=0),
"options": {"queue": settings.CELERY_TASK_DEFAULT_QUEUE},
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/core/celery_app.py` around lines 77 - 82, The scheduled task
definition "cleanup-expired-archives" in celery_app.py uses a relative interval
(schedule: 86400.0) which drifts on Beat restarts; replace that value with a
fixed crontab schedule by importing crontab from celery.schedules and setting
the task's schedule to crontab(hour=3, minute=0) for the
"app.tasks.archive_tasks.cleanup_expired_archives" task, leaving options
untouched so it consistently runs at 03:00 UTC.

},
# Beat scheduler class - Use default PersistentScheduler (file-based)
# Note: Only run ONE Celery Beat instance in production
Expand Down
31 changes: 31 additions & 0 deletions backend/app/schemas/kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,32 @@ class TaskApp(BaseModel):
previewUrl: str


class ArchiveInfo(BaseModel):
"""Workspace archive information for Pod recovery after deletion.

Stores metadata about archived workspace files that can be restored
when a user sends a message after the executor Pod has been deleted.
"""

storageKey: Optional[str] = Field(
None,
description="MinIO storage key (e.g., 'workspace-archives/12345/archive.tar.gz')",
)
archivedAt: Optional[datetime] = Field(
None, description="Timestamp when the workspace was archived"
)
expiresAt: Optional[datetime] = Field(
None, description="Timestamp when the archive expires (30 days after archiving)"
)
sizeBytes: Optional[int] = Field(None, description="Archive file size in bytes")
sessionFileIncluded: bool = Field(
False, description="Whether .claude_session_id file is included in the archive"
)
gitIncluded: bool = Field(
False, description="Whether .git directory is included in the archive"
)


class TaskStatus(Status):
"""Task status"""

Expand All @@ -548,6 +574,11 @@ class TaskStatus(Status):
completedAt: Optional[datetime] = None
subTasks: Optional[List[Dict[str, Any]]] = None
app: Optional[TaskApp] = None # App preview information
archive: Optional[ArchiveInfo] = Field(
None,
description="Workspace archive information for Pod recovery after deletion. "
"Set when executor Pod is deleted after task completion.",
)


class Task(BaseModel):
Expand Down
110 changes: 102 additions & 8 deletions backend/app/services/adapters/executor_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import List, Set
from typing import Dict, List, Set, Tuple

from sqlalchemy import and_
from sqlalchemy.orm import Session
Expand Down Expand Up @@ -131,18 +131,58 @@ def cleanup_stale_executors(self, db: Session) -> None:
)
return

# Deduplicate by (namespace, name)
unique_executor_keys: Set[tuple[str, str]] = set()
for s in valid_candidates:
if s.executor_name:
unique_executor_keys.add((s.executor_namespace, s.executor_name))
# Deduplicate by (namespace, name) and collect task info for archiving
# Map: (namespace, name) -> (subtask, task) for archive operation
executor_task_map: Dict[Tuple[str, str], Tuple[Subtask, TaskResource]] = {}
for subtask in valid_candidates:
if subtask.executor_name:
key = (subtask.executor_namespace, subtask.executor_name)
if key not in executor_task_map:
# Get task for this subtask
task = (
db.query(TaskResource)
.filter(
TaskResource.id == subtask.task_id,
TaskResource.kind == "Task",
TaskResource.is_active == TaskResource.STATE_ACTIVE,
)
.first()
)
if task:
executor_task_map[key] = (subtask, task)

if not unique_executor_keys:
if not executor_task_map:
return

# Use sync version to avoid event loop issues
for ns, name in unique_executor_keys:
for (ns, name), (subtask, task) in executor_task_map.items():
try:
# Archive workspace before deletion (for code tasks)
task_crd = Task.model_validate(task.json)
task_type = (
task_crd.metadata.labels
and task_crd.metadata.labels.get("taskType")
or "chat"
)

if task_type == "code":
# Try to archive workspace before deletion
try:
self._archive_workspace_sync(
db=db,
subtask=subtask,
task=task,
executor_name=name,
executor_namespace=ns,
)
except Exception as archive_error:
# Log but continue with deletion
# Recovery will fall back to git clone if archive failed
logger.warning(
f"[executor_job] Failed to archive workspace for task {task.id} "
f"ns={ns} name={name}: {archive_error}"
)
Comment on lines +168 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not delete code-task executors unless archiving actually succeeded.

_archive_workspace_sync() only logs success/skip/fail, and this call site still deletes the executor after archive exceptions. For code tasks, that can discard the only copy of uncommitted workspace state and session metadata; cleanup should leave the executor in place and retry later whenever no archive was produced.

Also applies to: 240-258

🧰 Tools
🪛 Ruff (0.15.7)

[warning] 178-178: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/services/adapters/executor_job.py` around lines 168 - 184, The
current code calls _archive_workspace_sync(...) for code tasks but proceeds to
delete the executor even if archiving failed or raised an exception; change
_archive_workspace_sync to return a clear status (e.g., True on archive
produced, False otherwise) or expose whether an archive was created, then update
the call sites (the try/except around _archive_workspace_sync in executor_job.py
and the similar block at the other location referenced) to: call
_archive_workspace_sync, capture its boolean result, and only proceed with
deletion when it returns True; on False or on exception, log the failure with
context (task.id, ns, name) and skip deletion so the executor stays in place for
retry later. Ensure both the call at the shown block and the analogous block
(lines ~240-258) use the same pattern and reference the _archive_workspace_sync
return value.


logger.info(
f"[executor_job] Scheduled deleting executor task ns={ns} name={name}"
)
Expand All @@ -166,5 +206,59 @@ def cleanup_stale_executors(self, db: Session) -> None:
except Exception as e:
logger.error(f"[executor_job] cleanup_stale_executors error: {e}")

def _archive_workspace_sync(
self,
db: Session,
subtask: Subtask,
task: TaskResource,
executor_name: str,
executor_namespace: str,
) -> None:
"""Archive workspace files before Pod deletion (synchronous wrapper).

This method wraps the async archive_service.archive_workspace() for use
in the synchronous cleanup_stale_executors job.

Args:
db: Database session
subtask: Subtask with executor info
task: Task resource
executor_name: Executor name
executor_namespace: Executor namespace
"""
from app.services.workspace_archive import archive_service

logger.info(
f"[executor_job] Archiving workspace for task {task.id}, "
f"executor={executor_namespace}/{executor_name}"
)

# Run async archive in new event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
archive_info = loop.run_until_complete(
archive_service.archive_workspace(
db=db,
subtask=subtask,
task=task,
executor_name=executor_name,
executor_namespace=executor_namespace,
)
)

if archive_info:
logger.info(
f"[executor_job] Workspace archived for task {task.id}, "
f"size={archive_info.sizeBytes} bytes"
)
else:
logger.info(
f"[executor_job] Workspace archiving skipped or failed for task {task.id}"
)
finally:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
Comment on lines +237 to +261

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

head -n 300 backend/app/services/adapters/executor_job.py | tail -n 100

Repository: wecode-ai/Wegent

Length of output: 3985


🏁 Script executed:

cd backend && find . -name "*.py" -type f | xargs rg "class JobService" -A 10

Repository: wecode-ai/Wegent

Length of output: 939


🏁 Script executed:

cd backend && rg "job_service\." -B 2 -A 2 | head -40

Repository: wecode-ai/Wegent

Length of output: 1972


🏁 Script executed:

cd backend && cat -n app/services/jobs.py | head -80

Repository: wecode-ai/Wegent

Length of output: 3029


🏁 Script executed:

cd backend && cat -n app/services/jobs.py | sed -n '75,150p'

Repository: wecode-ai/Wegent

Length of output: 3379


🏁 Script executed:

cd backend && cat -n app/services/jobs.py | sed -n '140,160p'

Repository: wecode-ai/Wegent

Length of output: 1038


🏁 Script executed:

cd backend && cat -n app/services/jobs.py | sed -n '159,180p'

Repository: wecode-ai/Wegent

Length of output: 900


🏁 Script executed:

cd backend && sed -n '171,280p' app/services/adapters/executor_job.py

Repository: wecode-ai/Wegent

Length of output: 3788


🌐 Web query:

asyncio event loop management background threads Python best practices

💡 Result:

Best practices for asyncio event loop management, especially with background threads in Python: 1. Use high-level entry points: Prefer asyncio.run(coro) or asyncio.Runner for managing the event loop lifecycle. These handle creation, running, cleanup (including shutdown_default_executor and close), and should be used as the main entry point. Avoid manual loop management unless necessary for low-level code. 2. Inside coroutines, use asyncio.get_running_loop instead of deprecated asyncio.get_event_loop. Do not use get_event_loop in async code. 3. For blocking synchronous code (CPU/IO-bound): Offload to thread pool using: - Python 3.9+: asyncio.to_thread(func, *args) for IO-bound (uses default ThreadPoolExecutor). - Or loop.run_in_executor(None, func, *args) or with custom concurrent.futures.ThreadPoolExecutor for more control. Use ProcessPoolExecutor for CPU-bound to bypass GIL. - asyncio.run handles default executor shutdown automatically. Example: import asyncio import concurrent.futures def blocking_io: ... async def main: loop = asyncio.get_running_loop result = await loop.run_in_executor(None, blocking_io) asyncio.run(main) 4. Background threads interacting with asyncio: - Event loop runs in one thread (usually main). asyncio objects are not thread-safe. - From other threads: Use loop.call_soon_threadsafe(callback) for callbacks, asyncio.run_coroutine_threadsafe(coro, loop) to schedule coroutines (returns concurrent.futures.Future). - To run asyncio in background thread: Create new loop with asyncio.new_event_loop, set with asyncio.set_event_loop(loop), then loop.run_forever. Submit tasks via thread-safe methods from main loop thread. Example for per-thread loops: import threading def thread_worker: loop = asyncio.new_event_loop asyncio.set_event_loop(loop) loop.run_forever # Start threads, submit from main via run_coroutine_threadsafe 5. Policies (deprecated in 3.14+, removal 3.16): Avoid set_event_loop_policy. Use loop_factory in asyncio.run(loop_factory=...) for custom loops (e.g., uvloop). 6. Multithreading best practices: - One event loop per thread. - Main thread for signals/subprocesses. - Enable debug mode (asyncio.run(debug=True)) for thread-safety checks, slow callbacks detection. - Close loops explicitly if not using asyncio.run: Call shutdown_asyncgens then close. - No direct coroutine scheduling from other processes (use multiprocessing separately). 7. General: - Avoid blocking the loop: Never run sync blocking code directly in coroutines. - Use create_task(coro) for concurrent async tasks, asyncio.gather for waiting. - For fire-and-forget: Track tasks in set to prevent GC. - Production: Consider uvloop for performance. These practices ensure responsiveness, thread-safety, and proper resource management per official Python 3.14+ docs.

Citations:


🏁 Script executed:

cd backend && python3 << 'EOF'
# Simulate the proposed fix behavior in a background thread context
import asyncio

def simulate_worker_thread_context():
    """Simulate what happens in cleanup_worker thread"""
    print("1. Starting in background thread (no event loop set)")
    
    # Try the proposed fix logic
    try:
        previous_loop = asyncio.get_event_loop_policy().get_event_loop()
        print(f"2. Got previous loop: {previous_loop}")
    except RuntimeError as e:
        previous_loop = None
        print(f"2. RuntimeError caught (expected in worker thread): {e}")
        print("   Set previous_loop = None")
    
    # Create new loop (as in _archive_workspace_sync)
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    print(f"3. Created and set new loop: {loop}")
    
    # Use and close it
    loop.close()
    print("4. Closed new loop")
    
    # Try to get event loop after closure
    try:
        current = asyncio.get_event_loop()
        print(f"5. After closure, get_event_loop() returned: {current}")
        if current.is_closed():
            print("   Loop is closed!")
    except RuntimeError as e:
        print(f"5. After closure, RuntimeError: {e}")
    
    # Restoration attempt (as in proposed fix)
    asyncio.set_event_loop(previous_loop)
    print(f"6. Restored previous_loop: {previous_loop}")

simulate_worker_thread_context()
EOF

Repository: wecode-ai/Wegent

Length of output: 528


🏁 Script executed:

cd backend && sed -n '75,95p' app/services/jobs.py

Repository: wecode-ai/Wegent

Length of output: 768


Restore the prior event loop after closing it in finally.

asyncio.set_event_loop(loop) makes this temporary loop current for the worker thread, but the finally block closes it without restoring the prior loop. Although the current code path contains only synchronous code after, this leaves a closed loop as the thread's current event loop, creating a latent bug if any async code is added later or if this method is reused elsewhere.

Proposed fix
-        loop = asyncio.new_event_loop()
-        asyncio.set_event_loop(loop)
+        try:
+            previous_loop = asyncio.get_event_loop_policy().get_event_loop()
+        except RuntimeError:
+            previous_loop = None
+
+        loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(loop)
         try:
             archive_info = loop.run_until_complete(
                 archive_service.archive_workspace(
                     db=db,
                     subtask=subtask,
@@
         finally:
             loop.run_until_complete(loop.shutdown_asyncgens())
             loop.close()
+            asyncio.set_event_loop(previous_loop)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/services/adapters/executor_job.py` around lines 237 - 261, Before
calling asyncio.set_event_loop(loop) capture the previous event loop (e.g.,
prev_loop = asyncio.get_event_loop()), and in the finally block restore it with
asyncio.set_event_loop(prev_loop) before shutting down and closing the temporary
loop used to run archive_service.archive_workspace; update the block around the
temporary loop creation in executor_job (the variables loop and
archive_service.archive_workspace call) so the thread's original event loop is
reinstated to avoid leaving a closed loop as the current loop.



job_service = JobService(Kind)
80 changes: 80 additions & 0 deletions backend/app/services/execution/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- ResultEmitter: Unified interface for emitting execution events
- TaskRequestBuilder: Builds ExecutionRequest from database models
- schedule_dispatch: Helper for dispatching tasks from sync context
- ExecutorRecoveryService: Recovers executor Pods after deletion
"""

from .dispatcher import ExecutionDispatcher, execution_dispatcher
Expand All @@ -28,10 +29,85 @@
StreamableEmitter,
WebSocketResultEmitter,
)
from .recovery_service import ExecutorRecoveryService, recovery_service
from .request_builder import TaskRequestBuilder
from .router import CommunicationMode, ExecutionRouter, ExecutionTarget
from .schedule_helper import schedule_dispatch


def get_sandbox_manager():
"""Get the SandboxManager instance for creating sandboxes.

Returns:
SandboxManager instance from executor_manager service
"""
import httpx

from app.core.config import settings

# For recovery, we need to call executor_manager's sandbox API
# This is a thin wrapper that will be replaced with direct call
# when executor_manager is properly integrated
return _SandboxManagerClient()


class _SandboxManagerClient:
"""Thin client for calling executor_manager sandbox API."""

async def create_sandbox(
self,
shell_type: str,
user_id: int,
user_name: str,
timeout: int = None,
workspace_ref: str = None,
bot_config: dict = None,
metadata: dict = None,
):
"""Create a sandbox via executor_manager API."""
import httpx

from app.core.config import settings

base_url = settings.EXECUTOR_MANAGER_URL.rstrip("/")
url = f"{base_url}/executor-manager/sandbox"

payload = {
"shell_type": shell_type,
"user_id": user_id,
"user_name": user_name,
"metadata": metadata or {},
}

if timeout:
payload["timeout"] = timeout
if workspace_ref:
payload["workspace_ref"] = workspace_ref
if bot_config:
payload["bot_config"] = bot_config

try:
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
data = response.json()

# Create a simple sandbox object
class SimpleSandbox:
def __init__(self, data):
self.sandbox_id = data.get("sandbox_id")
self.container_name = data.get("container_name")
self.base_url = data.get("base_url")

return SimpleSandbox(data), None
Comment on lines +89 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat executor-manager partial-failure responses as failures.

The executor-manager router can return a 200 response with an error when the sandbox record exists but container startup failed. This client currently wraps any 2xx body into SimpleSandbox, so recovery can proceed and clear executor_deleted_at even though the new executor never became ready.

Suggested guard for the response contract
                 response.raise_for_status()
                 data = response.json()
+
+                if data.get("error"):
+                    return None, data["error"]
+                if not data.get("base_url"):
+                    return None, "Sandbox creation did not reach running state"
 
                 # Create a simple sandbox object
                 class SimpleSandbox:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
data = response.json()
# Create a simple sandbox object
class SimpleSandbox:
def __init__(self, data):
self.sandbox_id = data.get("sandbox_id")
self.container_name = data.get("container_name")
self.base_url = data.get("base_url")
return SimpleSandbox(data), None
try:
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
data = response.json()
if data.get("error"):
return None, data["error"]
if not data.get("base_url"):
return None, "Sandbox creation did not reach running state"
# Create a simple sandbox object
class SimpleSandbox:
def __init__(self, data):
self.sandbox_id = data.get("sandbox_id")
self.container_name = data.get("container_name")
self.base_url = data.get("base_url")
return SimpleSandbox(data), None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/services/execution/__init__.py` around lines 89 - 106, The code
treats any 2xx response as success by constructing SimpleSandbox from the
response body; instead, inspect the response payload for error indicators (e.g.,
a top-level "error" key or missing required fields) after response.json() and
treat those as failures: in the try block where you call httpx.AsyncClient.post
and build SimpleSandbox(data), check for data.get("error") or absence of
sandbox_id/container_name/base_url and return (None, error) or raise an
exception so the caller doesn't proceed to clear executor_deleted_at; update the
SimpleSandbox construction logic in this function to only return a SimpleSandbox
when the contract is satisfied.

except Exception as e:
return None, str(e)


__all__ = [
# Router
"ExecutionRouter",
Expand All @@ -44,6 +120,10 @@
"TaskRequestBuilder",
# Schedule Helper
"schedule_dispatch",
# Recovery Service
"ExecutorRecoveryService",
"recovery_service",
"get_sandbox_manager",
# Emitters - Protocol
"ResultEmitter",
"StreamableEmitter",
Expand Down
Loading
Loading