Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3b9f692
docs: update contributors list
github-actions[bot] Mar 15, 2026
0a8526f
docs: update contributors list (zh)
github-actions[bot] Mar 15, 2026
ed8d9f0
Merge branch 'wecode-ai:main' into main
Micro66 Mar 16, 2026
123705f
Merge branch 'wecode-ai:main' into main
Micro66 Mar 16, 2026
1c6babd
Merge branch 'wecode-ai:main' into main
Micro66 Mar 17, 2026
12e5b6e
Merge branch 'wecode-ai:main' into main
Micro66 Mar 17, 2026
5a85836
Merge branch 'wecode-ai:main' into main
Micro66 Mar 17, 2026
4744710
Merge branch 'wecode-ai:main' into main
Micro66 Mar 17, 2026
0d283a5
Merge branch 'wecode-ai:main' into main
Micro66 Mar 18, 2026
b3deb29
Merge branch 'wecode-ai:main' into main
Micro66 Mar 18, 2026
0c66656
Merge branch 'wecode-ai:main' into main
Micro66 Mar 19, 2026
a137ef1
docs: update contributors list
github-actions[bot] Mar 22, 2026
5a5bd72
docs: update contributors list (zh)
github-actions[bot] Mar 22, 2026
0141f64
Merge branch 'wecode-ai:main' into main
Micro66 Mar 23, 2026
3cf43c0
Merge branch 'wecode-ai:main' into main
Micro66 Mar 23, 2026
dd1d11a
Merge branch 'wecode-ai:main' into main
Micro66 Mar 23, 2026
5f3baaa
Merge branch 'wecode-ai:main' into main
Micro66 Mar 23, 2026
ff1b9ac
Merge branch 'wecode-ai:main' into main
Micro66 Mar 23, 2026
1cf7b3b
Merge branch 'wecode-ai:main' into main
Micro66 Mar 24, 2026
7df10a1
Merge branch 'wecode-ai:main' into main
Micro66 Mar 24, 2026
d96baae
Merge branch 'wecode-ai:main' into main
Micro66 Mar 25, 2026
ca93caf
Merge branch 'wecode-ai:main' into main
Micro66 Mar 26, 2026
9c6dbef
Merge branch 'wecode-ai:main' into main
Micro66 Mar 26, 2026
4ed6a32
Merge branch 'wecode-ai:main' into main
Micro66 Mar 26, 2026
cc84293
Merge branch 'wecode-ai:main' into main
Micro66 Mar 26, 2026
6f481f7
fix: 修复无法使用自定义镜像
Micro66 Mar 26, 2026
ca54d3a
fix: 修复无法使用自定义镜像以及自定义镜像无法校验的问题
Micro66 Mar 27, 2026
d08b604
fix: 修复可能的bug
Micro66 Mar 27, 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
54 changes: 42 additions & 12 deletions backend/app/api/endpoints/adapter/shells.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# SPDX-License-Identifier: Apache-2.0

import asyncio
import logging
import os
import re
Expand All @@ -26,6 +27,18 @@
# Redis key prefix and TTL for validation status
VALIDATION_STATUS_KEY_PREFIX = "shell_validation:"
VALIDATION_STATUS_TTL = 300 # 5 minutes
# Delay before cleaning up validation container after successful validation.
# Default is immediate cleanup (0 seconds). Keeps backward compatibility with
# the previous variable name.
VALIDATION_CONTAINER_CLEANUP_DELAY_SECONDS = int(
os.getenv(
"VALIDATION_SUCCESS_CLEANUP_DELAY_SECONDS",
os.getenv("VALIDATION_CONTAINER_CLEANUP_DELAY_SECONDS", "0"),
)
)
VALIDATION_SUBMIT_TIMEOUT_SECONDS = float(
os.getenv("VALIDATION_SUBMIT_TIMEOUT_SECONDS", "90")
)


# Request/Response Models
Expand Down Expand Up @@ -745,7 +758,9 @@ async def validate_image(

# Call executor manager's validate-image API with validation_id
# Use AsyncClient to avoid blocking the event loop
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(
timeout=VALIDATION_SUBMIT_TIMEOUT_SECONDS
) as client:
response = await client.post(
validate_url,
json={
Expand Down Expand Up @@ -796,18 +811,24 @@ async def validate_image(
logger.error(f"Timeout submitting validation task for image: {image}")
await _update_validation_status(
validation_id,
status="completed",
stage="Error",
progress=100,
valid=False,
error_message="Request timed out while submitting validation task",
status="submitted",
stage="Submission timed out, validation may still be running",
progress=15,
valid=None,
error_message=(
"Submission request timed out. Validation may still be in progress; "
"continue polling status."
),
)
return ImageValidationResponse(
status="error",
message="Request timed out while submitting validation task",
status="submitted",
message=(
"Validation submission timed out, but task may still be running. "
"Please continue polling."
),
validationId=validation_id,
valid=False,
errors=["Validation request timed out"],
valid=None,
errors=["Validation submission timed out; continue polling"],
)
except httpx.RequestError as e:
logger.error(f"Error calling executor manager: {e}")
Expand Down Expand Up @@ -970,9 +991,10 @@ async def update_validation_status(
status_code=500, detail="Failed to update validation status"
)

# Cleanup validation container if validation is completed
# Cleanup validation container if validation is completed.
# Run cleanup asynchronously to avoid blocking callback response.
if request.executor_name and request.valid is True:
await _cleanup_validation_container(request.executor_name)
asyncio.create_task(_cleanup_validation_container(request.executor_name))
Comment on lines 996 to +997

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:

cat -n backend/app/api/endpoints/adapter/shells.py | sed -n '990,1035p'

Repository: wecode-ai/Wegent

Length of output: 2093


🏁 Script executed:

# Get the full context around the flagged lines to understand the function and cleanup mechanism
head -n 1050 backend/app/api/endpoints/adapter/shells.py | tail -n +980

Repository: wecode-ai/Wegent

Length of output: 2748


🏁 Script executed:

# Search for the _cleanup_validation_container function to understand its implementation
rg -n "_cleanup_validation_container" backend/app/api/endpoints/adapter/shells.py -A 15

Repository: wecode-ai/Wegent

Length of output: 1108


🏁 Script executed:

# Search for VALIDATION_CONTAINER_CLEANUP_DELAY_SECONDS to understand how delays are configured
rg -n "VALIDATION_CONTAINER_CLEANUP_DELAY_SECONDS" backend/

Repository: wecode-ai/Wegent

Length of output: 603


🏁 Script executed:

# Check if cleanup tasks are stored or tracked anywhere else in the file
rg -n "cleanup.*task\|pending.*task\|_cleanup" backend/app/api/endpoints/adapter/shells.py

Repository: wecode-ai/Wegent

Length of output: 42


🏁 Script executed:

# Check if there's any task tracking or background task management in this file
rg -n "background.*task\|task.*set\|pending.*task\|_cleanup" backend/app/api/endpoints/adapter/shells.py -B 2 -A 2

Repository: wecode-ai/Wegent

Length of output: 42


🏁 Script executed:

# Check imports at top of file to understand what's available
head -n 50 backend/app/api/endpoints/adapter/shells.py

Repository: wecode-ai/Wegent

Length of output: 1529


🏁 Script executed:

# Search for similar fire-and-forget patterns in the codebase to see if this is a consistent pattern
rg "asyncio\.create_task" backend/ -n | head -20

Repository: wecode-ai/Wegent

Length of output: 2112


Track the delayed cleanup task instead of dropping it.

This coroutine can spend time inside asyncio.sleep() with no strong reference. If the task is garbage collected or loses its reference, the cleanup will never execute and validation containers will remain on the executor. Keep pending cleanup tasks in a set or another lifecycle-managed runner, similar to patterns used elsewhere in the codebase like self._refresh_task = asyncio.create_task(...).

Also applies to lines 1022-1028.

🧰 Tools
🪛 Ruff (0.15.7)

[warning] 997-997: Store a reference to the return value of asyncio.create_task

(RUF006)

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

In `@backend/app/api/endpoints/adapter/shells.py` around lines 996 - 997, The
cleanup coroutine started with
asyncio.create_task(_cleanup_validation_container(request.executor_name)) is not
stored and may be garbage-collected before it runs; change this to create the
task and store a strong reference (e.g., add it to a lifecycle-managed set like
self._pending_cleanup_tasks) and register a done callback to remove it when
finished (use task.add_done_callback to pop from the set), doing the same for
the other places that spawn this cleanup (the similar block around the other
validation/cleanup calls). Ensure you reference the same task-handling pattern
used elsewhere (e.g., self._refresh_task) and keep the task referenced until
completion.


return {"status": "success", "message": "Validation status updated"}
except HTTPException:
Expand All @@ -997,6 +1019,14 @@ async def _cleanup_validation_container(executor_name: str) -> None:
logger.warning("No executor_name provided for cleanup")
return

if VALIDATION_CONTAINER_CLEANUP_DELAY_SECONDS > 0:
logger.info(
"Delaying validation container cleanup by %s seconds: %s",
VALIDATION_CONTAINER_CLEANUP_DELAY_SECONDS,
executor_name,
)
await asyncio.sleep(VALIDATION_CONTAINER_CLEANUP_DELAY_SECONDS)

executor_manager_url = os.getenv("EXECUTOR_MANAGER_URL", "http://localhost:8001")
delete_url = f"{executor_manager_url}/executor-manager/executor/delete"

Expand Down
75 changes: 54 additions & 21 deletions backend/app/services/execution/request_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ def __init__(self, db: Session):
db: Database session
"""
self.db = db
# Cache shell_type to avoid repeated database queries
self._cached_shell_type: str | None = None
# Cache shell info by (user_id, namespace, shell_name)
# to avoid repeated database queries while keeping per-shell isolation.
self._cached_shell_info: dict[tuple[int, str, str], dict] = {}

def build(
self,
Expand Down Expand Up @@ -643,6 +644,22 @@ def _resolve_shell_type(self, bot: Kind, user_id: int) -> str:
This method queries the Shell CRD to get the shell_type.
It's called once per builder instance and the result is cached.

Args:
bot: Bot Kind object
user_id: User ID for shell lookup

Returns:
Shell type string (e.g., "Chat", "ClaudeCode", "Agno")
"""
shell_info = self._resolve_shell_info(bot, user_id)
return shell_info["shell_type"]

def _resolve_shell_info(self, bot: Kind, user_id: int) -> dict:
"""Resolve shell info from bot's shellRef.

This method queries the Shell CRD to get shell_type and base_image.
It's called once per builder instance and the result is cached.

Search order:
1. User's private shell (user_id == user_id, namespace == shell_ref.namespace)
2. Group shell (any user_id, namespace == shell_ref.namespace) - for group scenarios
Expand All @@ -653,22 +670,29 @@ def _resolve_shell_type(self, bot: Kind, user_id: int) -> str:
user_id: User ID for shell lookup

Returns:
Shell type string (e.g., "Chat", "ClaudeCode", "Agno")
dict: {"shell_type": str, "base_image": Optional[str]}
"""
# Return cached value if available
if self._cached_shell_type is not None:
return self._cached_shell_type
bot_crd = Bot.model_validate(bot.json)

# Default value
# Default values
shell_type = "Chat"

bot_crd = Bot.model_validate(bot.json)
base_image = None

if not (bot_crd.spec and bot_crd.spec.shellRef):
self._cached_shell_type = shell_type
return shell_type
return {
"shell_type": shell_type,
"base_image": base_image,
}

shell_ref = bot_crd.spec.shellRef
cache_key = (user_id, shell_ref.namespace, shell_ref.name)

# Return cached value if available
cached = self._cached_shell_info.get(cache_key)
if cached is not None:
return cached

shell = None

# 1. Query user's private shell first
shell = (
Expand Down Expand Up @@ -710,22 +734,29 @@ def _resolve_shell_type(self, bot: Kind, user_id: int) -> str:
.first()
)

# Extract shell_type from Shell CRD
# Extract shell_type and base_image from Shell CRD
if shell and shell.json:
shell_crd = Shell.model_validate(shell.json)
if shell_crd.spec and shell_crd.spec.shellType:
shell_type = shell_crd.spec.shellType
if shell_crd.spec:
if shell_crd.spec.shellType:
shell_type = shell_crd.spec.shellType
base_image = shell_crd.spec.baseImage

logger.debug(
"[TaskRequestBuilder] Resolved shell_type=%s for bot=%s (shell_ref=%s/%s)",
shell_type,
"[TaskRequestBuilder] Resolved shell_info for bot=%s (shell_ref=%s/%s): shell_type=%s, base_image=%s",
bot_crd.metadata.name if bot_crd.metadata else "unknown",
shell_ref.namespace,
shell_ref.name,
shell_type,
base_image,
)

self._cached_shell_type = shell_type
return shell_type
resolved_shell_info = {
"shell_type": shell_type,
"base_image": base_image,
}
self._cached_shell_info[cache_key] = resolved_shell_info
return resolved_shell_info

# =========================================================================
# Skill Resolution (from ChatConfigBuilder)
Expand Down Expand Up @@ -1227,8 +1258,10 @@ def _build_bot_config(
bot_json = bot.json or {}
bot_spec_json = bot_json.get("spec", {})

# Get shell_type
shell_type = self._resolve_shell_type(bot, team.user_id)
# Get shell_type and base_image from Shell CRD
shell_info = self._resolve_shell_info(bot, team.user_id)
shell_type = shell_info["shell_type"]
base_image = shell_info["base_image"]

# Get ghost info for system_prompt and skills
ghost_system_prompt = ""
Expand Down Expand Up @@ -1272,7 +1305,7 @@ def _build_bot_config(
"mcp_servers": ghost_mcp_servers,
"skills": ghost_skills,
"role": member.role or "worker",
"base_image": bot_spec_json.get("baseImage"),
"base_image": base_image,
}
bot_configs.append(bot_config)

Expand Down
49 changes: 49 additions & 0 deletions backend/tests/services/execution/test_request_builder_user_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,52 @@ def test_get_bot_skills_returns_four_tuple_when_ghost_not_found(

assert len(result) == 4
assert result == ([], [], [], {})

def test_resolve_shell_info_cache_isolated_by_shell_ref(self, test_db, mocker):
builder = TaskRequestBuilder(test_db)

bot_one = SimpleNamespace(
json={
"kind": "Bot",
"metadata": {"name": "bot-one", "namespace": "default"},
"spec": {
"ghostRef": {"name": "ghost-a", "namespace": "default"},
"shellRef": {"name": "shell-a", "namespace": "default"},
},
}
)
bot_two = SimpleNamespace(
json={
"kind": "Bot",
"metadata": {"name": "bot-two", "namespace": "default"},
"spec": {
"ghostRef": {"name": "ghost-b", "namespace": "default"},
"shellRef": {"name": "shell-b", "namespace": "default"},
},
}
)

shell_a = SimpleNamespace(
json={
"kind": "Shell",
"metadata": {"name": "shell-a", "namespace": "default"},
"spec": {"shellType": "ClaudeCode", "baseImage": "img-a"},
}
)
shell_b = SimpleNamespace(
json={
"kind": "Shell",
"metadata": {"name": "shell-b", "namespace": "default"},
"spec": {"shellType": "Agno", "baseImage": "img-b"},
}
)

mock_query = mocker.Mock()
mock_query.filter.return_value.first.side_effect = [shell_a, shell_b]
mocker.patch.object(builder.db, "query", return_value=mock_query)

shell_info_one = builder._resolve_shell_info(bot_one, user_id=1)
shell_info_two = builder._resolve_shell_info(bot_two, user_id=1)

assert shell_info_one == {"shell_type": "ClaudeCode", "base_image": "img-a"}
assert shell_info_two == {"shell_type": "Agno", "base_image": "img-b"}
Loading
Loading