deer-flow/backend/tests/test_skills_cache_shared_reset.py
Nan Gao 0550816c50
fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create (#7099)
* fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create

Two leftovers from the multi-instance work (#7097).

The agent's skill_manage tool refreshed only its own process's skills
prompt cache after a create, edit, patch, delete, write_file or
remove_file, so a Gateway replica sharing the home volume kept rendering
that user's previous skill list. Every mutation now runs one drained tail,
as the skill routes do: the write and its history record, the local
refresh, then publish_skills_cache_reset(user_id=...) off the event loop.
A run cancelled while the write is in flight still refreshes and
publishes before the cancellation propagates. A marker that cannot be
written is logged inside the tail and raised as SkillCacheResetPublishError
with a message saying the change was applied, so the model does not retry
a write that succeeded.

Two provisioner replicas starting together against a missing
K8S_NAMESPACE both read 404 and both create; the loser's 409 escaped the
lifespan and crashed it. A 409 on create now counts as the namespace
existing, as the Pod and Service creates already do.

Also drops a stale duplicate of the multi-instance "declaration also
drives" note in CONFIGURATION.md.

* docs(changelog): reference #7099 for the multi-instance leftovers

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 22:28:19 +08:00

882 lines
41 KiB
Python

"""Cross-replica invalidation of the skills prompt caches.
``SkillStorage.load_skills()`` rescans disk and re-reads ``extensions_config.json``
on every call, so the only state that can go stale between Gateway replicas is
the prompt layer in ``deerflow.agents.lead_agent.prompt``: the global
enabled-skills cache, the per-``(app_config, user_id)`` cache and the rendered
``<skill_system>`` LRU. Every skill mutation handled by one replica therefore
publishes ``.<extensions config>.skills-cache-reset.json`` beside the shared
config; every other replica compares that marker's signature (throttled to one
stat per second) before serving a cached entry.
The prompt-layer tests play "process B" with the real module state and
"process A" by writing the marker directly, the way another replica would.
"""
from __future__ import annotations
import asyncio
import json
import threading
import zipfile
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi import FastAPI
from fastapi.testclient import TestClient
import deerflow.config.shared_reset_marker as shared_reset_marker_module
from app.gateway.auth.models import User
from app.gateway.deps import get_config
from app.gateway.routers import skills as skills_router
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.config.extensions_config import ExtensionsConfig, reset_extensions_config
from deerflow.config.file_signature import get_config_signature
from deerflow.config.paths import Paths
from deerflow.config.shared_reset_marker import SharedResetChange, SharedResetMarkerTracker
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
from deerflow.skills.types import Skill
from deerflow.tools import skill_manage_tool as skill_manage_module
_PROCESS_MESSAGE = "Skill caches invalidated; subsequent runs in this Gateway process will rescan the latest skills."
class _Clock:
def __init__(self, start: float = 1000.0) -> None:
self.now = start
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def _reset_prompt_cache_state() -> None:
prompt_module._get_cached_skills_prompt_section.cache_clear()
with prompt_module._enabled_skills_lock:
prompt_module._enabled_skills_cache = None
prompt_module._enabled_skills_by_config_cache.clear()
prompt_module._enabled_skills_refresh_active = False
prompt_module._enabled_skills_refresh_version = 0
prompt_module._enabled_skills_refresh_event.clear()
prompt_module._enabled_skills_refresh_waiters.clear()
prompt_module._skills_cache_reset_tracker.reset()
@pytest.fixture()
def shared_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""One extensions config shared by every simulated replica, plus a fake clock."""
config_path = tmp_path / "shared" / "extensions_config.json"
config_path.parent.mkdir()
config_path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
reset_extensions_config()
clock = _Clock()
monkeypatch.setattr(prompt_module, "_skills_cache_reset_tracker", SharedResetMarkerTracker(prompt_module.SKILLS_CACHE_RESET_MARKER, clock=clock))
_reset_prompt_cache_state()
try:
yield SimpleNamespace(path=config_path, clock=clock, marker=prompt_module.SKILLS_CACHE_RESET_MARKER)
finally:
_reset_prompt_cache_state()
reset_extensions_config()
def _marker_path(shared) -> Path:
return shared.marker.path_for(shared.path)
def _marker_payload(shared) -> dict:
return json.loads(_marker_path(shared).read_text(encoding="utf-8"))
def _write_public_skill(root: Path, name: str, description: str) -> None:
skill_dir = root / "public" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {description}\n---\n# {name}\n", encoding="utf-8")
def _skills_config(skills_root: Path) -> SimpleNamespace:
return SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: skills_root),
skill_evolution=SimpleNamespace(enabled=False, moderation_model_name=None),
)
def _install_prompt_storage(monkeypatch: pytest.MonkeyPatch, skills_root: Path) -> LocalSkillStorage:
storage = LocalSkillStorage(host_path=str(skills_root))
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda **_kwargs: storage)
monkeypatch.setattr(prompt_module, "get_or_new_user_skill_storage", lambda _user_id, **_kwargs: storage)
return storage
# --------------------------------------------------------------------------- #
# Prompt layer: replica B observes a marker written by replica A
# --------------------------------------------------------------------------- #
def test_peer_global_reset_invalidates_per_config_cache_and_prompt_section(shared_config, monkeypatch, tmp_path: Path) -> None:
skills_root = tmp_path / "skills"
_write_public_skill(skills_root, "changed-skill", "old description")
_install_prompt_storage(monkeypatch, skills_root)
config = _skills_config(skills_root)
before = prompt_module.get_enabled_skills_for_config(config, user_id="alice")
assert before[0].description == "old description"
assert "old description" in prompt_module.get_skills_prompt_section(app_config=config, user_id="alice")
assert prompt_module._get_cached_skills_prompt_section.cache_info().currsize == 1
# Replica A: the skill changed on the shared volume and A published a reset.
_write_public_skill(skills_root, "changed-skill", "new description")
shared_config.marker.publish(shared_config.path)
# Inside the throttle window B keeps serving its cache (bounded staleness).
assert prompt_module.get_enabled_skills_for_config(config, user_id="alice")[0].description == "old description"
shared_config.clock.advance(1.0)
after = prompt_module.get_enabled_skills_for_config(config, user_id="alice")
assert after[0].description == "new description"
assert prompt_module._get_cached_skills_prompt_section.cache_info().currsize == 0
assert "new description" in prompt_module.get_skills_prompt_section(app_config=config, user_id="alice")
def test_peer_user_scoped_reset_invalidates_only_that_user(shared_config, monkeypatch, tmp_path: Path) -> None:
skills_root = tmp_path / "skills"
_write_public_skill(skills_root, "changed-skill", "old description")
_install_prompt_storage(monkeypatch, skills_root)
config = _skills_config(skills_root)
assert prompt_module.get_enabled_skills_for_config(config, user_id="alice")[0].description == "old description"
assert prompt_module.get_enabled_skills_for_config(config, user_id="bob")[0].description == "old description"
_write_public_skill(skills_root, "changed-skill", "new description")
shared_config.marker.publish(shared_config.path, user_id="alice")
shared_config.clock.advance(1.0)
assert prompt_module.get_enabled_skills_for_config(config, user_id="alice")[0].description == "new description"
# Mirrors invalidate_user_skill_cache(): the other user's entry is untouched.
assert prompt_module.get_enabled_skills_for_config(config, user_id="bob")[0].description == "old description"
with prompt_module._enabled_skills_lock:
assert (id(config), "bob") in prompt_module._enabled_skills_by_config_cache
def test_peer_global_reset_refreshes_the_global_enabled_skills_cache(shared_config, monkeypatch, tmp_path: Path) -> None:
skills_root = tmp_path / "skills"
_write_public_skill(skills_root, "cached-skill", "old description")
storage = _install_prompt_storage(monkeypatch, skills_root)
old_skills = storage.load_skills(enabled_only=True)
_write_public_skill(skills_root, "cached-skill", "new description")
new_skills = storage.load_skills(enabled_only=True)
monkeypatch.setattr(prompt_module, "_load_enabled_skills_sync", lambda: list(new_skills))
with prompt_module._enabled_skills_lock:
prompt_module._enabled_skills_cache = list(old_skills)
assert prompt_module.get_cached_enabled_skills()[0].description == "old description"
shared_config.marker.publish(shared_config.path)
shared_config.clock.advance(1.0)
first_after = prompt_module.get_cached_enabled_skills()
assert prompt_module._enabled_skills_refresh_event.wait(timeout=5), "the reset must start the off-loop refresh"
assert prompt_module.get_cached_enabled_skills()[0].description == "new description"
# The last-known-good list is served while the refresh runs.
assert first_after[0].description in {"old description", "new description"}
def test_prompt_lookups_stat_the_marker_at_most_once_per_second(shared_config, monkeypatch, tmp_path: Path) -> None:
skills_root = tmp_path / "skills"
_write_public_skill(skills_root, "a-skill", "description")
_install_prompt_storage(monkeypatch, skills_root)
config = _skills_config(skills_root)
shared_config.marker.publish(shared_config.path)
reads: list[Path] = []
original = shared_reset_marker_module.read_config_with_signature
def _counting_read(path: Path):
reads.append(path)
return original(path)
monkeypatch.setattr(shared_reset_marker_module, "read_config_with_signature", _counting_read)
prompt_module.get_enabled_skills_for_config(config, user_id="alice")
prompt_module.get_enabled_skills_for_config(config, user_id="alice")
prompt_module.get_skills_prompt_section(app_config=config, user_id="alice")
assert reads == [_marker_path(shared_config)]
shared_config.clock.advance(1.0)
prompt_module.get_enabled_skills_for_config(config, user_id="alice")
assert len(reads) == 2
def test_publish_skills_cache_reset_without_config_path_is_process_local(shared_config, monkeypatch) -> None:
monkeypatch.setattr(prompt_module, "resolve_shared_config_path", lambda: None)
assert prompt_module.publish_skills_cache_reset() is None
assert not _marker_path(shared_config).exists()
def test_publish_skills_cache_reset_writes_marker_without_invalidating_the_publisher(shared_config, monkeypatch, tmp_path: Path) -> None:
skills_root = tmp_path / "skills"
_write_public_skill(skills_root, "a-skill", "description")
_install_prompt_storage(monkeypatch, skills_root)
config = _skills_config(skills_root)
prompt_module.get_enabled_skills_for_config(config, user_id="alice")
generation = prompt_module.publish_skills_cache_reset(user_id="alice")
assert generation is not None
assert _marker_payload(shared_config) == {
"version": 1,
"generation": generation,
"previous_generation": None,
"published_at": _marker_payload(shared_config)["published_at"],
"user_id": "alice",
}
shared_config.clock.advance(1.0)
prompt_module.get_enabled_skills_for_config(config, user_id="alice")
with prompt_module._enabled_skills_lock:
assert (id(config), "alice") in prompt_module._enabled_skills_by_config_cache, "the publishing process already refreshed itself"
# --------------------------------------------------------------------------- #
# Router: every mutation publishes the marker; reload reports its scope
# --------------------------------------------------------------------------- #
def _make_admin_user() -> User:
return User(email="admin-shared-reset@example.com", password_hash="x", system_role="admin", id=uuid4())
def _make_test_app(config) -> FastAPI:
if not hasattr(config, "authorization"):
config.authorization = AuthorizationConfig(enabled=False)
app = make_authed_test_app(user_factory=_make_admin_user)
app.state.config = config
app.dependency_overrides[get_config] = lambda: config
app.include_router(skills_router.router)
return app
def _skill_content(name: str, description: str = "Demo skill") -> str:
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
async def _allow_scan(*_args, **_kwargs):
from deerflow.skills.security_scanner import ScanResult
return ScanResult(decision="allow", reason="ok")
def _user_custom_dir(base_dir: Path, user_id: str = "default") -> Path:
return base_dir / "users" / user_id / "skills" / "custom"
@pytest.fixture()
def custom_skill_app(shared_config, monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"""An admin app whose caller owns one editable custom skill on disk."""
skills_root = tmp_path / "skills"
custom_dir = _user_custom_dir(tmp_path) / "demo-skill"
custom_dir.mkdir(parents=True)
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setattr(skills_router, "scan_skill_content", _allow_scan)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
events: list[tuple[str, str | None]] = []
async def _refresh(user_id: str) -> None:
events.append(("refresh", user_id))
real_publish = skills_router.publish_skills_cache_reset
def _publish(*, user_id=None):
events.append(("publish", user_id))
return real_publish(user_id=user_id)
monkeypatch.setattr(skills_router, "refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skills_router, "publish_skills_cache_reset", _publish)
return SimpleNamespace(app=_make_test_app(config), config=config, events=events, custom_dir=custom_dir, skills_root=skills_root, base_dir=tmp_path)
def test_custom_skill_edit_publishes_user_scoped_marker_after_local_refresh(shared_config, custom_skill_app) -> None:
assert not _marker_path(shared_config).exists()
with TestClient(custom_skill_app.app) as client:
response = client.put("/api/skills/custom/demo-skill", json={"content": _skill_content("demo-skill", "Edited skill")})
assert response.status_code == 200, response.text
assert custom_skill_app.events == [("refresh", "default"), ("publish", "default")]
assert _marker_payload(shared_config)["user_id"] == "default"
def test_custom_skill_rollback_publishes_marker(shared_config, custom_skill_app) -> None:
with TestClient(custom_skill_app.app) as client:
assert client.put("/api/skills/custom/demo-skill", json={"content": _skill_content("demo-skill", "Edited skill")}).status_code == 200
after_edit = get_config_signature(_marker_path(shared_config))
response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
assert response.status_code == 200, response.text
assert response.json()["description"] == "Demo skill"
assert get_config_signature(_marker_path(shared_config)) != after_edit
assert custom_skill_app.events[-2:] == [("refresh", "default"), ("publish", "default")]
def test_custom_skill_delete_publishes_marker(shared_config, custom_skill_app) -> None:
with TestClient(custom_skill_app.app) as client:
response = client.delete("/api/skills/custom/demo-skill")
assert response.status_code == 200, response.text
assert not (custom_skill_app.custom_dir / "SKILL.md").exists()
assert custom_skill_app.events == [("refresh", "default"), ("publish", "default")]
assert _marker_payload(shared_config)["user_id"] == "default"
def test_archive_install_publishes_marker(shared_config, custom_skill_app, monkeypatch, tmp_path: Path) -> None:
archive = tmp_path / "install-skill.skill"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("install-skill/SKILL.md", _skill_content("install-skill"))
storage = UserScopedSkillStorage("default", host_path=str(custom_skill_app.skills_root))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _allow_scan)
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
with TestClient(custom_skill_app.app) as client:
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/install-skill.skill"})
assert response.status_code == 200, response.text
assert (_user_custom_dir(custom_skill_app.base_dir) / "install-skill").exists()
assert custom_skill_app.events == [("refresh", "default"), ("publish", "default")]
assert _marker_payload(shared_config)["user_id"] == "default"
def test_archive_upload_publishes_marker(shared_config, custom_skill_app, monkeypatch) -> None:
class _Storage:
async def ainstall_skill_from_archive(self, archive_path: Path) -> dict:
return {"success": True, "skill_name": "uploaded-skill", "message": "Skill installed successfully"}
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: _Storage())
with TestClient(custom_skill_app.app) as client:
response = client.post("/api/skills/install/upload", files={"archive": ("uploaded-skill.skill", b"skill archive bytes", "application/octet-stream")})
assert response.status_code == 200, response.text
assert custom_skill_app.events == [("refresh", "default"), ("publish", "default")]
assert _marker_payload(shared_config)["user_id"] == "default"
def test_public_skill_toggle_publishes_global_marker(shared_config, monkeypatch) -> None:
def _load_skills(*, enabled_only: bool):
enabled = ExtensionsConfig.from_file(str(shared_config.path)).skills.get("public-skill", SimpleNamespace(enabled=True)).enabled
skill = Skill(
name="public-skill",
description="Description for public-skill",
license="MIT",
skill_dir=Path("/tmp/public-skill"),
skill_file=Path("/tmp/public-skill/SKILL.md"),
relative_path=Path("public-skill"),
category="public",
enabled=enabled,
)
return [] if enabled_only and not enabled else [skill]
events: list[tuple[str, str | None]] = []
real_publish = skills_router.publish_skills_cache_reset
def _publish(*, user_id=None):
events.append(("publish", user_id))
return real_publish(user_id=user_id)
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: SimpleNamespace(load_skills=_load_skills))
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
monkeypatch.setattr(skills_router, "clear_skills_system_prompt_cache", lambda: events.append(("clear", None)))
monkeypatch.setattr(skills_router, "publish_skills_cache_reset", _publish)
with TestClient(_make_test_app(SimpleNamespace())) as client:
response = client.put("/api/skills/public-skill", json={"enabled": False})
assert response.status_code == 200, response.text
assert response.json()["enabled"] is False
assert json.loads(shared_config.path.read_text(encoding="utf-8"))["skills"]["public-skill"]["enabled"] is False
assert events == [("clear", None), ("publish", None)]
assert "user_id" not in _marker_payload(shared_config)
def test_custom_skill_toggle_publishes_user_scoped_marker(shared_config, monkeypatch) -> None:
from deerflow.skills.storage import user_scoped_skill_storage as uss_module
enabled_state = {"value": True}
class _FakeUserScopedStorage:
def load_skills(self, *, enabled_only: bool = False):
skill = Skill(
name="demo-skill",
description="Description for demo-skill",
license="MIT",
skill_dir=Path("/tmp/demo-skill"),
skill_file=Path("/tmp/demo-skill/SKILL.md"),
relative_path=Path("demo-skill"),
category="custom",
enabled=enabled_state["value"],
)
return [] if enabled_only and not skill.enabled else [skill]
def set_skill_enabled_state(self, name: str, enabled: bool) -> None:
enabled_state["value"] = enabled
events: list[tuple[str, str | None]] = []
async def _refresh(user_id: str) -> None:
events.append(("refresh", user_id))
real_publish = skills_router.publish_skills_cache_reset
def _publish(*, user_id=None):
events.append(("publish", user_id))
return real_publish(user_id=user_id)
monkeypatch.setattr(uss_module, "UserScopedSkillStorage", _FakeUserScopedStorage)
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: _FakeUserScopedStorage())
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
monkeypatch.setattr(skills_router, "refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skills_router, "publish_skills_cache_reset", _publish)
with TestClient(_make_test_app(SimpleNamespace())) as client:
response = client.put("/api/skills/demo-skill", json={"enabled": False})
assert response.status_code == 200, response.text
assert events == [("refresh", "default"), ("publish", "default")]
assert _marker_payload(shared_config)["user_id"] == "default"
def _make_reload_app() -> FastAPI:
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: "/tmp/skills", container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
app = make_authed_test_app(user_factory=_make_admin_user)
app.state.config = config
app.dependency_overrides[get_config] = lambda: config
app.include_router(skills_router.router)
return app
def test_reload_publishes_marker_and_reports_shared_config_scope(shared_config, monkeypatch) -> None:
events: list[str] = []
async def _refresh() -> None:
events.append("refresh")
real_publish = skills_router.publish_skills_cache_reset
def _publish(*, user_id=None):
events.append("publish")
return real_publish(user_id=user_id)
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skills_router, "publish_skills_cache_reset", _publish)
with TestClient(_make_reload_app()) as client:
first = client.post("/api/skills/reload")
first_signature = get_config_signature(_marker_path(shared_config))
second = client.post("/api/skills/reload")
assert first.status_code == 200, first.text
assert first.json()["success"] is True
assert first.json()["scope"] == "shared_config"
assert "shared config directory" in first.json()["message"]
assert events == ["refresh", "publish", "refresh", "publish"]
assert second.status_code == 200
assert get_config_signature(_marker_path(shared_config)) != first_signature
assert "user_id" not in _marker_payload(shared_config)
def test_reload_without_config_path_falls_back_to_process_scope(shared_config, monkeypatch) -> None:
async def _refresh() -> None:
return None
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(prompt_module, "resolve_shared_config_path", lambda: None)
with TestClient(_make_reload_app()) as client:
response = client.post("/api/skills/reload")
assert response.status_code == 200, response.text
assert response.json() == {"success": True, "scope": "process", "message": _PROCESS_MESSAGE}
assert not _marker_path(shared_config).exists()
def test_reload_marker_publication_failure_is_a_generic_server_error(shared_config, monkeypatch) -> None:
"""A 200 must never mean that only the handling process was refreshed."""
async def _refresh() -> None:
return None
def _failing_publish(*, user_id=None):
raise OSError("read-only volume at /srv/company/minio")
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skills_router, "publish_skills_cache_reset", _failing_publish)
with TestClient(_make_reload_app()) as client:
response = client.post("/api/skills/reload")
assert response.status_code == 500
assert response.json() == {"detail": "Failed to invalidate skills cache."}
assert "/srv/company/minio" not in response.text
# --------------------------------------------------------------------------- #
# Review follow-ups: cancellation drains the reload tail; publication errors
# are never reported as 404
# --------------------------------------------------------------------------- #
def _admin_request() -> SimpleNamespace:
return SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
@pytest.mark.asyncio
async def test_reload_drains_refresh_and_publish_across_cancellation(shared_config, monkeypatch) -> None:
"""A caller cancelled after the local refresh must not skip the shared marker.
``/skills/reload`` is the operator hook for external mount writes: if the
cancellation unwound between the refresh and the publish, every peer
replica would keep the old skill set, the one outcome the endpoint exists
to prevent. Mirrors the drained-tail tests of the other mutation routes.
"""
started = asyncio.Event()
release = asyncio.Event()
async def _blocked_refresh() -> None:
started.set()
await release.wait()
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _blocked_refresh)
assert not _marker_path(shared_config).exists()
task = asyncio.create_task(skills_router.reload_skills(_admin_request()))
try:
await asyncio.wait_for(started.wait(), 5)
task.cancel()
await asyncio.sleep(0.05)
task.cancel()
await asyncio.sleep(0.05)
assert not task.done(), "the drained tail must keep running until it settles"
release.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert _marker_path(shared_config).exists(), "the shared marker must be published even though the caller was cancelled"
assert "user_id" not in _marker_payload(shared_config)
def _marker_write_raises_file_not_found(monkeypatch: pytest.MonkeyPatch, shared_config) -> str:
"""Simulate the config directory vanishing between path resolution and the atomic write."""
missing = str(_marker_path(shared_config))
def _publish(config_path, *, user_id=None):
raise FileNotFoundError(2, "No such file or directory", missing)
monkeypatch.setattr(prompt_module.SKILLS_CACHE_RESET_MARKER, "publish", _publish)
return missing
def test_install_marker_publication_failure_is_500_not_404(shared_config, custom_skill_app, monkeypatch, tmp_path: Path) -> None:
"""A FileNotFoundError from the marker write must not borrow the 'archive not found' status."""
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
archive = tmp_path / "install-skill.skill"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("install-skill/SKILL.md", _skill_content("install-skill"))
storage = UserScopedSkillStorage("default", host_path=str(custom_skill_app.skills_root))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _allow_scan)
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
with TestClient(custom_skill_app.app) as client:
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/install-skill.skill"})
assert response.status_code == 500, response.text
assert response.json()["detail"].startswith("Failed to install skill:")
assert missing not in response.text
# The install itself succeeded; only the cross-replica publication failed.
assert (_user_custom_dir(custom_skill_app.base_dir) / "install-skill").exists()
assert custom_skill_app.events == [("refresh", "default"), ("publish", "default")]
def test_edit_marker_publication_failure_is_500_not_404(shared_config, custom_skill_app, monkeypatch) -> None:
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
with TestClient(custom_skill_app.app) as client:
response = client.put("/api/skills/custom/demo-skill", json={"content": _skill_content("demo-skill", "Edited skill")})
assert response.status_code == 500, response.text
assert response.json()["detail"].startswith("Failed to update custom skill:")
assert missing not in response.text
assert "Edited skill" in (custom_skill_app.custom_dir / "SKILL.md").read_text(encoding="utf-8")
def test_delete_marker_publication_failure_is_500_not_404(shared_config, custom_skill_app, monkeypatch) -> None:
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
with TestClient(custom_skill_app.app) as client:
response = client.delete("/api/skills/custom/demo-skill")
assert response.status_code == 500, response.text
assert response.json()["detail"].startswith("Failed to delete custom skill:")
assert missing not in response.text
def test_rollback_marker_publication_failure_is_500_not_404(shared_config, custom_skill_app, monkeypatch) -> None:
with TestClient(custom_skill_app.app) as client:
assert client.put("/api/skills/custom/demo-skill", json={"content": _skill_content("demo-skill", "Edited skill")}).status_code == 200
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
assert response.status_code == 500, response.text
assert response.json()["detail"].startswith("Failed to roll back custom skill:")
assert missing not in response.text
def test_public_toggle_marker_publication_failure_is_500(shared_config, monkeypatch) -> None:
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
skill = Skill(
name="public-skill",
description="Description for public-skill",
license="MIT",
skill_dir=Path("/tmp/public-skill"),
skill_file=Path("/tmp/public-skill/SKILL.md"),
relative_path=Path("public-skill"),
category="public",
enabled=True,
)
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: SimpleNamespace(load_skills=lambda *, enabled_only: [skill]))
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
monkeypatch.setattr(skills_router, "clear_skills_system_prompt_cache", lambda: None)
with TestClient(_make_test_app(SimpleNamespace())) as client:
response = client.put("/api/skills/public-skill", json={"enabled": False})
assert response.status_code == 500, response.text
assert response.json()["detail"].startswith("Failed to update skill:")
assert missing not in response.text
assert json.loads(shared_config.path.read_text(encoding="utf-8"))["skills"]["public-skill"]["enabled"] is False
def test_reload_marker_write_file_not_found_is_a_generic_server_error(shared_config, monkeypatch) -> None:
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
async def _refresh() -> None:
return None
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
with TestClient(_make_reload_app()) as client:
response = client.post("/api/skills/reload")
assert response.status_code == 500
assert response.json() == {"detail": "Failed to invalidate skills cache."}
assert missing not in response.text
def test_publish_skills_cache_reset_wraps_filesystem_errors(shared_config, monkeypatch) -> None:
"""The dedicated error type is not an OSError, so no handler can map it to 404/400."""
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
with pytest.raises(prompt_module.SkillCacheResetPublishError) as excinfo:
prompt_module.publish_skills_cache_reset(user_id="alice")
assert not isinstance(excinfo.value, OSError)
assert isinstance(excinfo.value.__cause__, FileNotFoundError)
assert missing not in str(excinfo.value)
# --------------------------------------------------------------------------- #
# Agent tool: skill_manage publishes the same marker as the routes
# --------------------------------------------------------------------------- #
_AGENT_USER = "alice"
_AGENT_SKILL = "agent-skill"
@pytest.fixture()
def agent_skill_tool(shared_config, monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"""The agent's ``skill_manage`` tool on per-user storage under ``tmp_path``.
Only the LLM scan is stubbed: the local refresh and the marker write are
the real ones, wrapped so ``events`` records their order and
``publish_threads`` the thread each marker write ran on.
"""
skills_root = tmp_path / "skills"
skill_dir = _user_custom_dir(tmp_path, _AGENT_USER) / _AGENT_SKILL
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setattr(skill_manage_module, "scan_skill_content", _allow_scan)
events: list[tuple[str, str | None]] = []
publish_threads: list[int] = []
real_refresh = skill_manage_module.refresh_user_skills_system_prompt_cache_async
real_publish = shared_config.marker.publish
async def _refresh(user_id: str) -> None:
events.append(("refresh", user_id))
await real_refresh(user_id)
def _publish(config_path, *, user_id=None):
events.append(("publish", user_id))
publish_threads.append(threading.get_ident())
return real_publish(config_path, user_id=user_id)
monkeypatch.setattr(skill_manage_module, "refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(shared_config.marker, "publish", _publish)
def _install() -> None:
"""Put an editable skill with one support file on disk, bypassing the tool."""
(skill_dir / "references").mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(_skill_content(_AGENT_SKILL), encoding="utf-8")
(skill_dir / "references" / "guide.md").write_text("guide", encoding="utf-8")
def _call(**kwargs):
runtime = SimpleNamespace(
context={"thread_id": "thread-1", "user_id": _AGENT_USER},
config={"configurable": {"thread_id": "thread-1", "user_id": _AGENT_USER}},
)
return skill_manage_module.skill_manage_tool.coroutine(runtime=runtime, name=_AGENT_SKILL, **kwargs)
return SimpleNamespace(call=_call, install=_install, events=events, publish_threads=publish_threads, skill_file=skill_dir / "SKILL.md")
_AGENT_MUTATIONS = [
pytest.param({"action": "create", "content": _skill_content(_AGENT_SKILL)}, False, id="create"),
pytest.param({"action": "edit", "content": _skill_content(_AGENT_SKILL, "Edited skill")}, True, id="edit"),
pytest.param({"action": "patch", "find": "Demo skill", "replace": "Patched skill"}, True, id="patch"),
pytest.param({"action": "write_file", "path": "references/guide.md", "content": "new guide"}, True, id="write_file"),
pytest.param({"action": "remove_file", "path": "references/guide.md"}, True, id="remove_file"),
pytest.param({"action": "delete"}, True, id="delete"),
]
@pytest.mark.parametrize("arguments, existing", _AGENT_MUTATIONS)
def test_skill_manage_tool_mutation_publishes_user_scoped_marker_after_local_refresh(shared_config, agent_skill_tool, arguments, existing) -> None:
"""An agent-driven skill change reaches peers the way a route-driven one does.
The tool used to refresh only its own process, so a replica sharing the
home volume kept rendering the previous skill list for that user.
"""
if existing:
agent_skill_tool.install()
peer_clock = _Clock()
peer = SharedResetMarkerTracker(shared_config.marker, clock=peer_clock)
assert peer.poll() is None
result = asyncio.run(agent_skill_tool.call(**arguments))
assert _AGENT_SKILL in result
assert agent_skill_tool.events == [("refresh", _AGENT_USER), ("publish", _AGENT_USER)]
assert agent_skill_tool.publish_threads and threading.get_ident() not in agent_skill_tool.publish_threads, "the marker write is filesystem IO and must run off the event loop"
peer_clock.advance(1.0)
assert peer.poll() == SharedResetChange(user_ids=frozenset({_AGENT_USER}), generation=_marker_payload(shared_config)["generation"])
def test_skill_manage_tool_rejected_mutation_publishes_nothing(shared_config, agent_skill_tool) -> None:
agent_skill_tool.install()
with pytest.raises(ValueError, match="already exists"):
asyncio.run(agent_skill_tool.call(action="create", content=_skill_content(_AGENT_SKILL)))
assert agent_skill_tool.events == []
assert not _marker_path(shared_config).exists()
def test_skill_manage_tool_marker_publication_failure_reports_the_applied_change(shared_config, agent_skill_tool, monkeypatch, caplog) -> None:
"""The skill is on disk; the model must learn that only peer notification failed.
The error stays a ``SkillCacheResetPublishError``, never an ``OSError`` or
``ValueError`` (which the tool raises for rejected input that changed
nothing), and names no server path. It is also logged inside the drained
tail, where a cancelled run cannot lose it.
"""
missing = _marker_write_raises_file_not_found(monkeypatch, shared_config)
with caplog.at_level("ERROR", logger=skill_manage_module.logger.name), pytest.raises(prompt_module.SkillCacheResetPublishError) as excinfo:
asyncio.run(agent_skill_tool.call(action="create", content=_skill_content(_AGENT_SKILL)))
assert not isinstance(excinfo.value, (OSError, ValueError))
assert isinstance(excinfo.value.__cause__, prompt_module.SkillCacheResetPublishError)
message = str(excinfo.value)
assert f"'{_AGENT_SKILL}'" in message and "was applied" in message
assert missing not in message
assert agent_skill_tool.skill_file.read_text(encoding="utf-8") == _skill_content(_AGENT_SKILL)
assert agent_skill_tool.events == [("refresh", _AGENT_USER)]
assert [record.message for record in caplog.records if record.name == skill_manage_module.logger.name] == [f"Custom skill '{_AGENT_SKILL}' changed, but the shared skills cache reset could not be published"]
@pytest.mark.asyncio
async def test_skill_manage_tool_drains_write_refresh_and_publish_across_cancellation(shared_config, agent_skill_tool, monkeypatch) -> None:
"""A run cancelled while the skill write is in flight must not skip the marker.
Cancelling ``asyncio.to_thread`` abandons the await, not the worker, so the
write lands anyway; unwinding before the refresh and the publication would
leave this process and every peer serving the previous skill list. Mirrors
the drained mutation tails of the skill routes.
"""
await asyncio.to_thread(agent_skill_tool.install)
entered = threading.Event()
release = threading.Event()
to_thread = skill_manage_module._to_thread
async def _parking_to_thread(func, /, *args, **kwargs):
if getattr(func, "__name__", None) != "write_custom_skill":
return await to_thread(func, *args, **kwargs)
def _parked():
entered.set()
assert release.wait(5), "test never released the parked write"
return func(*args, **kwargs)
return await to_thread(_parked)
monkeypatch.setattr(skill_manage_module, "_to_thread", _parking_to_thread)
edited = _skill_content(_AGENT_SKILL, "Edited skill")
task = asyncio.create_task(agent_skill_tool.call(action="edit", content=edited))
try:
assert await asyncio.to_thread(entered.wait, 5)
task.cancel()
await asyncio.sleep(0.05)
task.cancel()
await asyncio.sleep(0.05)
assert not task.done(), "the drained tail must keep running until it settles"
release.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert agent_skill_tool.events == [("refresh", _AGENT_USER), ("publish", _AGENT_USER)]
assert _marker_payload(shared_config)["user_id"] == _AGENT_USER
assert await asyncio.to_thread(agent_skill_tool.skill_file.read_text, encoding="utf-8") == edited