agent-Specialization/server/tasks/skills.py
JOJO 3ceace2762 fix(skills): skill 文件读取等待同步窗口,避免并发全量同步误判不存在
工作区 skills 目录会被并发全量同步(rmtree+重建)短暂清空:每个新对话级 terminal 实例各触发一次同步(标记 _skills_synced_project_path 挂实例),实测新会话开始 2.5s 内并发 4 次全量同步,_resolve_workspace_skill_path 一次 is_file() 失败即 400「skill 文件不存在」。新增 wait_skill_file_ready(复用同步锁有界等待本轮重建 + 200ms 轮询,预算 2.5s),读方改走该原语;补 4 项单测(共 7 项全过)。缓存踩踏/rmtree 全量重建等架构层竞态按决议暂不处理,方向见项目记忆 conversation_vs_workspace_terminal。
2026-08-02 00:00:41 +08:00

140 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Workspace skill 读取与 /api/skills 接口。"""
from __future__ import annotations
from server.tasks import tasks_bp
import re
from pathlib import Path
from typing import Dict, Any, List
from flask import request, jsonify
from flask import session
from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources
from server.utils_common import debug_log
from config import WORKSPACE_SKILLS_DIRNAME
from modules.skills_manager import wait_skill_file_ready
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
SKILL_FIELD_RE = re.compile(r"^(?P<key>name|description)\s*:\s*(?P<value>.*)$")
def _parse_skill_metadata(content: str, fallback_name: str) -> Dict[str, str]:
metadata = {"name": fallback_name, "description": ""}
match = SKILL_FRONTMATTER_RE.match(content or "")
if not match:
return metadata
for raw_line in match.group("body").splitlines():
field = SKILL_FIELD_RE.match(raw_line.strip())
if not field:
continue
key = field.group("key")
value = field.group("value").strip().strip('"').strip("'")
metadata[key] = value
metadata["name"] = metadata.get("name") or fallback_name
return metadata
def _workspace_skills_dir(workspace) -> Path:
return (Path(workspace.project_path).expanduser().resolve() / WORKSPACE_SKILLS_DIRNAME).resolve()
def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path:
try:
skills_dir = _workspace_skills_dir(workspace)
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot compute skills_dir: {exc}")
raise ValueError(f"无法定位工作区 skills 目录: {exc}") from exc
debug_log(f"[SkillsAPI] resolve skill skills_dir={skills_dir!r} raw_path={raw_path!r}")
try:
target = Path(str(raw_path or "")).expanduser()
if not target.is_absolute():
target = Path(workspace.project_path).expanduser().resolve() / target
target = target.resolve()
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot resolve path: {exc}")
raise ValueError(f"skill 路径解析失败: {exc}") from exc
debug_log(f"[SkillsAPI] resolve skill resolved_target={target!r} name={target.name!r}")
if target.name != "SKILL.md":
raise ValueError(f"skill 路径必须指向 SKILL.md: {target.name}")
try:
target.relative_to(skills_dir)
except ValueError as exc:
debug_log(f"[SkillsAPI] resolve skill path outside skills_dir: target={target!r} skills_dir={skills_dir!r}")
raise ValueError("skill 路径必须位于当前工作区 .astrion/skills/ 内") from exc
# 工作区 skills 目录可能被并发的全量同步rmtree+重建)短暂清空,
# 直接 is_file() 一次失败会把瞬时窗口误判为「skill 文件不存在」拒绝任务,
# 这里改为有界等待重试,等过同步窗口再下结论。
if not wait_skill_file_ready(target, skills_dir):
debug_log(f"[SkillsAPI] resolve skill file not found (after wait): {target!r}")
raise ValueError("skill 文件不存在")
return target
def _list_workspace_skills(workspace) -> List[Dict[str, str]]:
skills_dir = _workspace_skills_dir(workspace)
if not skills_dir.is_dir():
return []
result: List[Dict[str, str]] = []
for skill_file in sorted(skills_dir.glob("*/SKILL.md"), key=lambda p: p.parent.name.lower()):
try:
content = skill_file.read_text(encoding="utf-8")
except Exception:
continue
metadata = _parse_skill_metadata(content, skill_file.parent.name)
result.append({
"name": metadata.get("name") or skill_file.parent.name,
"description": metadata.get("description") or "",
"path": str(skill_file.resolve()),
})
return result
def _build_skill_context_messages(workspace, raw_refs: Any) -> List[Dict[str, str]]:
if not isinstance(raw_refs, list):
debug_log(f"[SkillsAPI] build_skill_context raw_refs is not list: {type(raw_refs).__name__}")
return []
messages: List[Dict[str, str]] = []
seen_paths = set()
for idx, item in enumerate(raw_refs[:10]):
if not isinstance(item, dict):
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] not dict: {item!r}")
continue
raw_path = str(item.get("path") or "").strip()
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] raw_path={raw_path!r} name={item.get('name')!r}")
if not raw_path:
continue
skill_file = _resolve_workspace_skill_path(workspace, raw_path)
path_key = str(skill_file)
if path_key in seen_paths:
debug_log(f"[SkillsAPI] build_skill_context duplicate path skipped: {path_key}")
continue
seen_paths.add(path_key)
try:
content = skill_file.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"skill 文件编码错误,无法读取: {skill_file.name}") from exc
except OSError as exc:
raise ValueError(f"读取 skill 文件失败: {exc}") from exc
metadata = _parse_skill_metadata(content, skill_file.parent.name)
messages.append({
"name": metadata.get("name") or skill_file.parent.name,
"path": path_key,
"content": content,
})
return messages
@tasks_bp.route("/api/skills", methods=["GET"])
@api_login_required
def list_skills_api():
username = get_current_username()
workspace_id = session.get("workspace_id") or "default"
try:
_terminal, workspace = get_user_resources(username, workspace_id)
if not workspace:
return jsonify({"success": False, "error": "工作区不可用"}), 400
return jsonify({
"success": True,
"skills": _list_workspace_skills(workspace),
})
except Exception as exc:
debug_log(f"[SkillsAPI] 列出技能失败: {exc}")
return jsonify({"success": False, "error": str(exc)}), 500