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

@ -93,6 +93,22 @@ class PluginManager:
finally:
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(
self,
manifest: PluginManifest,
@ -158,6 +174,108 @@ class PluginManager:
)
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:
skill_name = str(item["skill_name"])
version: SkillVersion = item["version"]
@ -261,3 +379,13 @@ def _utc_now() -> str:
from datetime import datetime, timezone
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"