Compare commits

..

No commits in common. "3ceace2762cd86f82b79de2563636e2eb5757ddf" and "32633f7eec6f6cb9277a222153edd5306b786a05" have entirely different histories.

5 changed files with 4 additions and 129 deletions

View File

@ -8,7 +8,6 @@ from __future__ import annotations
import re
import shutil
import threading
import time
from pathlib import Path
from typing import Dict, List, Optional, Sequence
@ -33,52 +32,6 @@ def _get_sync_lock(skills_dir: Path) -> threading.Lock:
return lock
def wait_skill_file_ready(
skill_file: str | Path,
skills_dir: str | Path,
*,
max_wait_seconds: float = 2.5,
poll_interval: float = 0.2,
) -> bool:
"""Wait until a workspace skill file exists / 等待工作区 skill 文件就绪。
工作区 skills 目录可能被并发的全量同步rmtree + 重建短暂清空
读取方在窗口期会看到文件不存在这里先等当前这一轮同步结束
复用同步锁做有界等待再以固定间隔轮询到预算耗尽避免把瞬时
缺失误判为硬错误
返回 True 表示文件已存在超过预算仍不存在返回 False
"""
target = Path(skill_file)
if target.is_file():
return True
try:
lock: Optional[threading.Lock] = _get_sync_lock(Path(skills_dir))
except Exception:
lock = None
deadline = time.monotonic() + max(0.0, max_wait_seconds)
while True:
if lock is not None:
try:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
# 锁空闲时会立即拿到并释放,开销可忽略;
# 锁被同步占用则有界等到本轮重建完成。
acquired = lock.acquire(timeout=remaining)
if acquired:
lock.release()
except Exception:
pass
if target.is_file():
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(poll_interval, remaining))
return target.is_file()
def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path:
"""Ensure the global skills directory exists / 确保全局技能目录存在。"""
root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()

View File

@ -12,7 +12,6 @@ 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)
@ -61,11 +60,8 @@ def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path:
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}")
if not target.is_file():
debug_log(f"[SkillsAPI] resolve skill file not found: {target!r}")
raise ValueError("skill 文件不存在")
return target

View File

@ -3,8 +3,7 @@
import { debugLog } from '../common';
import { useModelStore } from '../../../stores/model';
// 兼容 Windows 反斜杠路径skills 目录与 SKILL.md 前的分隔符同时接受 / 和 \
export const SKILL_MARKDOWN_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*[\\/]\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g;
export const SKILL_MARKDOWN_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.astrion\/skills\/[^)\n]+\/SKILL\.md)\)/g;
export function extractSkillRefsFromMessage(message = '') {
const refs = [];

View File

@ -1053,8 +1053,7 @@ const escapeUserHtml = (value: string): string =>
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Windows skills SKILL.md / \
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*[\\/]\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g;
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.astrion\/skills\/[^)\n]+\/SKILL\.md)\)/g;
const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g;
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/;

View File

@ -2,8 +2,6 @@ from __future__ import annotations
import os
import tempfile
import threading
import time
import unittest
from pathlib import Path
@ -11,13 +9,11 @@ from pathlib import Path
os.environ["TERMINAL_SANDBOX_MODE"] = "web"
from modules.skills_manager import (
_get_sync_lock,
archive_skill_directory,
get_skills_catalog,
infer_private_skills_dir,
sync_workspace_skills,
validate_skill_directory,
wait_skill_file_ready,
)
@ -101,73 +97,5 @@ class SkillsManagerTest(unittest.TestCase):
self.assertTrue((project / ".astrion" / "skills" / "private-skill" / "SKILL.md").exists())
class WaitSkillFileReadyTest(unittest.TestCase):
"""读取方等待原语覆盖并发全量同步rmtree+重建)的瞬时窗口。"""
def test_existing_file_returns_true_immediately(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skills_dir = root / ".astrion" / "skills"
target = skills_dir / "demo" / "SKILL.md"
target.parent.mkdir(parents=True)
target.write_text("x", encoding="utf-8")
start = time.monotonic()
self.assertTrue(wait_skill_file_ready(target, skills_dir, max_wait_seconds=0.5))
self.assertLess(time.monotonic() - start, 0.2)
def test_file_appearing_during_wait_returns_true(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skills_dir = root / ".astrion" / "skills"
skills_dir.mkdir(parents=True)
target = skills_dir / "demo" / "SKILL.md"
def create_later():
time.sleep(0.3)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("x", encoding="utf-8")
threading.Thread(target=create_later, daemon=True).start()
self.assertTrue(
wait_skill_file_ready(target, skills_dir, max_wait_seconds=2.0, poll_interval=0.05)
)
def test_in_flight_sync_lock_is_awaited(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skills_dir = root / ".astrion" / "skills"
skills_dir.mkdir(parents=True)
target = skills_dir / "demo" / "SKILL.md"
lock = _get_sync_lock(skills_dir)
def fake_sync():
with lock:
time.sleep(0.3)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("x", encoding="utf-8")
threading.Thread(target=fake_sync, daemon=True).start()
# 等假同步先拿到锁,模拟读取方撞上重建窗口
time.sleep(0.05)
self.assertFalse(target.is_file())
self.assertTrue(
wait_skill_file_ready(target, skills_dir, max_wait_seconds=2.0, poll_interval=0.05)
)
def test_missing_file_returns_false_within_budget(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skills_dir = root / ".astrion" / "skills"
skills_dir.mkdir(parents=True)
target = skills_dir / "nope" / "SKILL.md"
start = time.monotonic()
self.assertFalse(
wait_skill_file_ready(target, skills_dir, max_wait_seconds=0.4, poll_interval=0.05)
)
elapsed = time.monotonic() - start
self.assertGreaterEqual(elapsed, 0.4)
self.assertLess(elapsed, 1.5)
if __name__ == "__main__":
unittest.main()