Compare commits
2 Commits
32633f7eec
...
3ceace2762
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ceace2762 | |||
| ca0d87d9b9 |
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Sequence
|
from typing import Dict, List, Optional, Sequence
|
||||||
|
|
||||||
@ -32,6 +33,52 @@ def _get_sync_lock(skills_dir: Path) -> threading.Lock:
|
|||||||
return 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:
|
def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path:
|
||||||
"""Ensure the global skills directory exists / 确保全局技能目录存在。"""
|
"""Ensure the global skills directory exists / 确保全局技能目录存在。"""
|
||||||
root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()
|
root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from server.auth_helpers import api_login_required, get_current_username
|
|||||||
from server.context import get_user_resources
|
from server.context import get_user_resources
|
||||||
from server.utils_common import debug_log
|
from server.utils_common import debug_log
|
||||||
from config import WORKSPACE_SKILLS_DIRNAME
|
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_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
|
||||||
@ -60,8 +61,11 @@ def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
debug_log(f"[SkillsAPI] resolve skill path outside skills_dir: target={target!r} skills_dir={skills_dir!r}")
|
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
|
raise ValueError("skill 路径必须位于当前工作区 .astrion/skills/ 内") from exc
|
||||||
if not target.is_file():
|
# 工作区 skills 目录可能被并发的全量同步(rmtree+重建)短暂清空,
|
||||||
debug_log(f"[SkillsAPI] resolve skill file not found: {target!r}")
|
# 直接 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 文件不存在")
|
raise ValueError("skill 文件不存在")
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
import { debugLog } from '../common';
|
import { debugLog } from '../common';
|
||||||
import { useModelStore } from '../../../stores/model';
|
import { useModelStore } from '../../../stores/model';
|
||||||
|
|
||||||
export const SKILL_MARKDOWN_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.astrion\/skills\/[^)\n]+\/SKILL\.md)\)/g;
|
// 兼容 Windows 反斜杠路径:skills 目录与 SKILL.md 前的分隔符同时接受 / 和 \
|
||||||
|
export const SKILL_MARKDOWN_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*[\\/]\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g;
|
||||||
|
|
||||||
export function extractSkillRefsFromMessage(message = '') {
|
export function extractSkillRefsFromMessage(message = '') {
|
||||||
const refs = [];
|
const refs = [];
|
||||||
|
|||||||
@ -1053,7 +1053,8 @@ const escapeUserHtml = (value: string): string =>
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.astrion\/skills\/[^)\n]+\/SKILL\.md)\)/g;
|
// 兼容 Windows 反斜杠路径:skills 目录与 SKILL.md 前的分隔符同时接受 / 和 \
|
||||||
|
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*[\\/]\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g;
|
||||||
const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g;
|
const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g;
|
||||||
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
|
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
|
||||||
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/;
|
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/;
|
||||||
|
|||||||
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -9,11 +11,13 @@ from pathlib import Path
|
|||||||
os.environ["TERMINAL_SANDBOX_MODE"] = "web"
|
os.environ["TERMINAL_SANDBOX_MODE"] = "web"
|
||||||
|
|
||||||
from modules.skills_manager import (
|
from modules.skills_manager import (
|
||||||
|
_get_sync_lock,
|
||||||
archive_skill_directory,
|
archive_skill_directory,
|
||||||
get_skills_catalog,
|
get_skills_catalog,
|
||||||
infer_private_skills_dir,
|
infer_private_skills_dir,
|
||||||
sync_workspace_skills,
|
sync_workspace_skills,
|
||||||
validate_skill_directory,
|
validate_skill_directory,
|
||||||
|
wait_skill_file_ready,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -97,5 +101,73 @@ class SkillsManagerTest(unittest.TestCase):
|
|||||||
self.assertTrue((project / ".astrion" / "skills" / "private-skill" / "SKILL.md").exists())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user