feat(plugins): enqueue skill upgrade candidates

This commit is contained in:
2026-06-16 11:47:15 +08:00
parent 994710e232
commit c9e6c37b5c
5 changed files with 316 additions and 37 deletions

View File

@ -8,7 +8,7 @@ import pytest
from beaver.foundation.utils.file_lock import WorkspaceWriteLock
from beaver.memory.skills import SkillLearningStore
from beaver.plugins.discovery import discover_plugins
from beaver.plugins.skills import PluginManager
from beaver.plugins.skills import PluginManager, classify_plugin_skill_update
from beaver.plugins.state import PluginStateStore
from beaver.skills.catalog.loader import SkillsLoader
from beaver.skills.learning.safety import SkillDraftSafetyChecker
@ -55,6 +55,24 @@ def _write_skill_plugin(
return plugin_root
def _rewrite_plugin_version(plugin_root: Path, *, version: str, skill_text: str | None = None, template: str | None = None) -> None:
manifest_path = plugin_root / "beaver.plugin.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["version"] = version
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
skill_name = manifest["skills"][0]["name"]
skill_root = plugin_root / "skills" / skill_name
if skill_text is not None:
(skill_root / "SKILL.md").write_text(
"---\nname: {0}\ndescription: Comic workflow\ntools: []\n---\n\n{1}".format(skill_name, skill_text),
encoding="utf-8",
)
if template is not None:
target = skill_root / "templates" / "panel.txt"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(template, encoding="utf-8")
def _manager(workspace: Path) -> PluginManager:
discovery = discover_plugins(workspace, search_paths=[])
skill_store = SkillSpecStore(workspace)
@ -143,3 +161,76 @@ def test_enable_plugin_is_idempotent(tmp_path: Path) -> None:
assert first.status == "synced"
assert second.status == "synced"
assert SkillSpecStore(workspace).list_versions("baoyu-comic") == ["v0001"]
@pytest.mark.parametrize(
("base", "local", "upstream", "expected"),
[
("A", "A", "A", "unchanged"),
("A", "B", "B", "already_applied"),
("A", "A", "B", "fast_forward"),
("A", "LOCAL", "UPSTREAM", "three_way"),
],
)
def test_classify_plugin_skill_update(base: str, local: str, upstream: str, expected: str) -> None:
assert classify_plugin_skill_update(base, local, upstream) == expected
def test_sync_enabled_creates_idempotent_fast_forward_candidate_for_supporting_file_update(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
plugin_root = _write_skill_plugin(workspace / "plugins", extra_files={"templates/panel.txt": "v1"})
manager = _manager(workspace)
manager.enable("baoyu-comic")
_rewrite_plugin_version(plugin_root, version="1.1.0", template="v2")
first = _manager(workspace).sync_enabled()
second = _manager(workspace).sync_enabled()
candidates = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()
assert first["baoyu-comic"].skills["baoyu-comic"].status == "update_pending"
assert second["baoyu-comic"].skills["baoyu-comic"].status == "update_pending"
assert len(candidates) == 1
candidate = candidates[0]
assert candidate.kind == "plugin_skill_update"
assert candidate.candidate_id.startswith("plugin-update:baoyu-comic:baoyu-comic:")
assert candidate.evidence["merge_mode"] == "fast_forward"
assert "Draw panels" not in json.dumps(candidate.evidence)
def test_sync_enabled_creates_three_way_candidate_when_local_diverged(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
plugin_root = _write_skill_plugin(workspace / "plugins")
manager = _manager(workspace)
manager.enable("baoyu-comic")
store = SkillSpecStore(workspace)
loaded = store.read_published_skill("baoyu-comic")
assert loaded is not None
local_version = loaded.version
local_version.version = "v0002"
local_version.parent_version = "v0001"
store.write_skill_version(local_version, loaded.content + "\nLocal learning.\n")
store.set_current_version("baoyu-comic", "v0002")
_rewrite_plugin_version(plugin_root, version="1.1.0", skill_text="# Baoyu Comic\n\nUpstream change.\n")
_manager(workspace).sync_enabled()
candidate = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()[0]
assert candidate.evidence["merge_mode"] == "three_way"
assert candidate.evidence["local_version"] == "v0002"
def test_sync_enabled_supersedes_stale_pending_update(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
plugin_root = _write_skill_plugin(workspace / "plugins")
_manager(workspace).enable("baoyu-comic")
_rewrite_plugin_version(plugin_root, version="1.1.0", skill_text="# Baoyu Comic\n\nFirst update.\n")
_manager(workspace).sync_enabled()
first_candidate = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()[0]
_rewrite_plugin_version(plugin_root, version="1.2.0", skill_text="# Baoyu Comic\n\nSecond update.\n")
_manager(workspace).sync_enabled()
candidates = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()
assert len(candidates) == 2
assert {candidate.status for candidate in candidates} == {"open", "superseded"}
assert any(candidate.candidate_id != first_candidate.candidate_id for candidate in candidates)

View File

@ -76,6 +76,35 @@ def test_legacy_candidate_payload_is_backward_compatible(tmp_path: Path) -> None
assert candidate.updated_at
def test_record_learning_candidate_if_absent_is_idempotent(tmp_path: Path) -> None:
store = SkillLearningStore(tmp_path)
candidate = SkillLearningCandidate(
candidate_id="plugin-update:baoyu-comic:baoyu-comic:abcdef123456",
kind="plugin_skill_update",
source_run_ids=[],
source_session_ids=[],
related_skill_names=["baoyu-comic"],
reason="Plugin update",
evidence={
"plugin_id": "baoyu-comic",
"plugin_version": "1.1.0",
"skill_name": "baoyu-comic",
"merge_mode": "fast_forward",
"base_upstream_tree_hash": "old",
"new_upstream_tree_hash": "new",
"local_version": "v0001",
},
)
first, first_created = store.record_learning_candidate_if_absent(candidate)
second, second_created = store.record_learning_candidate_if_absent(candidate)
assert first_created is True
assert second_created is False
assert first.candidate_id == second.candidate_id
assert len(store.list_learning_candidates()) == 1
def test_safety_and_eval_reports_round_trip(tmp_path: Path) -> None:
store = SkillLearningStore(tmp_path)
safety = SkillDraftSafetyReport(