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

@ -4,7 +4,12 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
import threading
from uuid import uuid4 from uuid import uuid4
from contextlib import contextmanager
from typing import Iterator
from beaver.foundation.utils.file_lock import WorkspaceWriteLock
from .models import ( from .models import (
SkillDraftEvalReport, SkillDraftEvalReport,
@ -16,9 +21,11 @@ from .models import (
class SkillLearningStore: class SkillLearningStore:
def __init__(self, root: str | Path) -> None: def __init__(self, root: str | Path, *, write_lock: WorkspaceWriteLock | None = None) -> None:
self.root = Path(root) self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True) self.root.mkdir(parents=True, exist_ok=True)
self.write_lock = write_lock
self._local_lock = threading.RLock()
self.performance_path = self.root / "performance.jsonl" self.performance_path = self.root / "performance.jsonl"
self.candidates_path = self.root / "learning-candidates.jsonl" self.candidates_path = self.root / "learning-candidates.jsonl"
self.audit_path = self.root / "learning-audit.jsonl" self.audit_path = self.root / "learning-audit.jsonl"
@ -26,42 +33,58 @@ class SkillLearningStore:
self.eval_reports_dir = self.root / "eval-reports" self.eval_reports_dir = self.root / "eval-reports"
def record_learning_candidate(self, candidate: SkillLearningCandidate) -> None: def record_learning_candidate(self, candidate: SkillLearningCandidate) -> None:
self.record_learning_candidate_if_absent(candidate)
def record_learning_candidate_if_absent(
self,
candidate: SkillLearningCandidate,
) -> tuple[SkillLearningCandidate, bool]:
normalized = SkillLearningCandidate.from_dict(candidate.to_dict()) normalized = SkillLearningCandidate.from_dict(candidate.to_dict())
self._append_jsonl(self.candidates_path, normalized.to_dict()) with self._locked():
self.append_audit_event( existing = {
normalized.candidate_id, item.candidate_id: item
"candidate_created", for item in self.list_learning_candidates()
{ }
"kind": normalized.kind, found = existing.get(normalized.candidate_id)
"status": normalized.status, if found is not None:
"reason": normalized.reason, return found, False
}, self._append_jsonl(self.candidates_path, normalized.to_dict())
) self.append_audit_event(
normalized.candidate_id,
"candidate_created",
{
"kind": normalized.kind,
"status": normalized.status,
"reason": normalized.reason,
},
)
return normalized, True
def update_learning_candidate(self, candidate_id: str, **updates: object) -> SkillLearningCandidate | None: def update_learning_candidate(self, candidate_id: str, **updates: object) -> SkillLearningCandidate | None:
candidates = self.list_learning_candidates() with self._locked():
updated: SkillLearningCandidate | None = None candidates = self.list_learning_candidates()
for index, candidate in enumerate(candidates): updated: SkillLearningCandidate | None = None
if candidate.candidate_id != candidate_id: for index, candidate in enumerate(candidates):
continue if candidate.candidate_id != candidate_id:
payload = candidate.to_dict() continue
payload.update(updates) payload = candidate.to_dict()
if "updated_at" not in updates: payload.update(updates)
payload["updated_at"] = _utc_now() if "updated_at" not in updates:
updated = SkillLearningCandidate.from_dict(payload) payload["updated_at"] = _utc_now()
candidates[index] = updated updated = SkillLearningCandidate.from_dict(payload)
break candidates[index] = updated
if updated is None: break
return None if updated is None:
self.candidates_path.parent.mkdir(parents=True, exist_ok=True) return None
self.candidates_path.write_text( self.candidates_path.parent.mkdir(parents=True, exist_ok=True)
"".join( self.candidates_path.write_text(
json.dumps(candidate.to_dict(), ensure_ascii=False, sort_keys=True) + "\n" "".join(
for candidate in candidates json.dumps(candidate.to_dict(), ensure_ascii=False, sort_keys=True) + "\n"
), for candidate in candidates
encoding="utf-8", ),
) encoding="utf-8",
return updated )
return updated
def transition_learning_candidate( def transition_learning_candidate(
self, self,
@ -209,6 +232,15 @@ class SkillLearningStore:
raise ValueError(f"Expected JSON object in {path}") raise ValueError(f"Expected JSON object in {path}")
return payload return payload
@contextmanager
def _locked(self) -> Iterator[None]:
if self.write_lock is not None:
with self.write_lock.acquire(timeout_seconds=10):
yield
return
with self._local_lock:
yield
def _utc_now() -> str: def _utc_now() -> str:
from datetime import datetime, timezone from datetime import datetime, timezone

View File

@ -93,6 +93,22 @@ class PluginManager:
finally: finally:
transaction.cleanup() transaction.cleanup()
def sync_enabled(self, *, blocking: bool = True) -> dict[str, PluginState]:
results: dict[str, PluginState] = {}
with self.write_lock.acquire(timeout_seconds=10, blocking=blocking):
for state in self.state_store.list_plugins():
manifest = self.manifests.get(state.plugin_id)
if not state.enabled or state.updates_paused:
results[state.plugin_id] = state
continue
if manifest is None:
state.status = "missing"
self.state_store.upsert_plugin(state)
results[state.plugin_id] = state
continue
results[state.plugin_id] = self._sync_plugin(state, manifest)
return results
def _prepare_initial_mirror( def _prepare_initial_mirror(
self, self,
manifest: PluginManifest, manifest: PluginManifest,
@ -158,6 +174,108 @@ class PluginManager:
) )
return prepared return prepared
def _sync_plugin(self, state: PluginState, manifest: PluginManifest) -> PluginState:
transaction = PluginSkillTransaction(self.workspace)
try:
for declaration in manifest.skills:
binding = state.skills.get(declaration.name)
if binding is None or not binding.accepted_upstream_tree_hash:
continue
snapshot = self.skill_store.stage_upstream_snapshot(
transaction,
skill_name=declaration.name,
source_kind="plugin",
source_id=manifest.plugin_id,
source_version=manifest.version,
source_path=declaration.relative_path,
source_root=declaration.root,
)
self.skill_store.promote_upstream_snapshot(transaction, snapshot)
current = self.skill_store.read_published_skill(declaration.name)
if current is None:
continue
classification = classify_plugin_skill_update(
binding.accepted_upstream_tree_hash,
current.version.tree_hash,
snapshot.skill_tree_hash,
)
binding.observed_upstream_tree_hash = snapshot.skill_tree_hash
binding.current_beaver_version = current.version.version
if classification == "unchanged":
binding.status = "synced"
continue
if classification == "already_applied":
binding.accepted_upstream_tree_hash = snapshot.skill_tree_hash
binding.accepted_beaver_version = current.version.version
binding.pending_candidate_id = None
binding.status = "synced"
continue
candidate = self._create_update_candidate(
plugin_id=manifest.plugin_id,
plugin_version=manifest.version,
skill_name=declaration.name,
merge_mode=classification,
base_upstream_tree_hash=binding.accepted_upstream_tree_hash,
new_upstream_tree_hash=snapshot.skill_tree_hash,
local_version=current.version.version,
)
if binding.pending_candidate_id and binding.pending_candidate_id != candidate.candidate_id:
self.learning_store.transition_learning_candidate(
binding.pending_candidate_id,
"superseded",
event_type="plugin_update_superseded",
payload={"replacement_candidate_id": candidate.candidate_id},
)
recorded, _created = self.learning_store.record_learning_candidate_if_absent(candidate)
binding.pending_candidate_id = recorded.candidate_id
binding.status = "update_pending"
state.installed_version = manifest.version
state.manifest_path = manifest.display_path
if any(binding.status == "update_pending" for binding in state.skills.values()):
state.status = "update_pending"
else:
state.status = "synced"
self.state_store.upsert_plugin(state)
return state
finally:
transaction.cleanup()
@staticmethod
def _create_update_candidate(
*,
plugin_id: str,
plugin_version: str,
skill_name: str,
merge_mode: str,
base_upstream_tree_hash: str,
new_upstream_tree_hash: str,
local_version: str,
):
from beaver.memory.skills.models import SkillLearningCandidate
candidate_id = f"plugin-update:{plugin_id}:{skill_name}:{new_upstream_tree_hash[:12]}"
return SkillLearningCandidate(
candidate_id=candidate_id,
kind="plugin_skill_update",
source_run_ids=[],
source_session_ids=[],
related_skill_names=[skill_name],
reason=f"Plugin {plugin_id} has an update for skill {skill_name}.",
evidence={
"plugin_id": plugin_id,
"plugin_version": plugin_version,
"skill_name": skill_name,
"merge_mode": merge_mode,
"base_upstream_tree_hash": base_upstream_tree_hash,
"new_upstream_tree_hash": new_upstream_tree_hash,
"local_version": local_version,
},
status="open",
priority=10,
confidence=1.0,
trigger_reason="plugin_update",
)
def _publish_initial_mirror(self, item: dict[str, Any]) -> None: def _publish_initial_mirror(self, item: dict[str, Any]) -> None:
skill_name = str(item["skill_name"]) skill_name = str(item["skill_name"])
version: SkillVersion = item["version"] version: SkillVersion = item["version"]
@ -261,3 +379,13 @@ def _utc_now() -> str:
from datetime import datetime, timezone from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def classify_plugin_skill_update(base_tree: str, local_tree: str, upstream_tree: str) -> str:
if upstream_tree == base_tree:
return "unchanged"
if local_tree == upstream_tree:
return "already_applied"
if local_tree == base_tree:
return "fast_forward"
return "three_way"

View File

@ -174,8 +174,7 @@ class SkillSpecStore:
version_dir = self._skill_dir(version.skill_name) / "versions" / version.version version_dir = self._skill_dir(version.skill_name) / "versions" / version.version
version_dir.mkdir(parents=True, exist_ok=True) version_dir.mkdir(parents=True, exist_ok=True)
self._write_text(version_dir / "SKILL.md", content) self._write_text(version_dir / "SKILL.md", content)
if not version.tree_hash: version.tree_hash = hash_plugin_skill_tree(version_dir).skill_tree_hash
version.tree_hash = hash_plugin_skill_tree(version_dir).skill_tree_hash
self._write_json(version_dir / "version.json", version.to_dict()) self._write_json(version_dir / "version.json", version.to_dict())
def stage_upstream_snapshot( def stage_upstream_snapshot(

View File

@ -8,7 +8,7 @@ import pytest
from beaver.foundation.utils.file_lock import WorkspaceWriteLock from beaver.foundation.utils.file_lock import WorkspaceWriteLock
from beaver.memory.skills import SkillLearningStore from beaver.memory.skills import SkillLearningStore
from beaver.plugins.discovery import discover_plugins 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.plugins.state import PluginStateStore
from beaver.skills.catalog.loader import SkillsLoader from beaver.skills.catalog.loader import SkillsLoader
from beaver.skills.learning.safety import SkillDraftSafetyChecker from beaver.skills.learning.safety import SkillDraftSafetyChecker
@ -55,6 +55,24 @@ def _write_skill_plugin(
return plugin_root 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: def _manager(workspace: Path) -> PluginManager:
discovery = discover_plugins(workspace, search_paths=[]) discovery = discover_plugins(workspace, search_paths=[])
skill_store = SkillSpecStore(workspace) skill_store = SkillSpecStore(workspace)
@ -143,3 +161,76 @@ def test_enable_plugin_is_idempotent(tmp_path: Path) -> None:
assert first.status == "synced" assert first.status == "synced"
assert second.status == "synced" assert second.status == "synced"
assert SkillSpecStore(workspace).list_versions("baoyu-comic") == ["v0001"] 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 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: def test_safety_and_eval_reports_round_trip(tmp_path: Path) -> None:
store = SkillLearningStore(tmp_path) store = SkillLearningStore(tmp_path)
safety = SkillDraftSafetyReport( safety = SkillDraftSafetyReport(