Compare commits
No commits in common. "3ceace2762cd86f82b79de2563636e2eb5757ddf" and "32633f7eec6f6cb9277a222153edd5306b786a05" have entirely different histories.
3ceace2762
...
32633f7eec
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -1053,8 +1053,7 @@ const escapeUserHtml = (value: string): string =>
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// 兼容 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*任务摘要[::]/;
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user