feat(skill-learning): merge plugin skill updates

This commit is contained in:
2026-06-16 11:55:55 +08:00
parent c9e6c37b5c
commit a34b1219bc
15 changed files with 860 additions and 5 deletions

View File

@ -13,7 +13,6 @@ from .models import (
PluginState,
)
from .state import PluginStateStore
from .skills import PluginManager
__all__ = [
"PluginDiscoveryError",
@ -25,7 +24,6 @@ __all__ = [
"PluginSkillTreeDigest",
"PluginState",
"PluginStateStore",
"PluginManager",
"hash_plugin_skill_tree",
"load_plugin_manifest",
]

View File

@ -109,6 +109,77 @@ class PluginManager:
results[state.plugin_id] = self._sync_plugin(state, manifest)
return results
def pause(self, plugin_id: str) -> PluginState:
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
state.updates_paused = True
self.state_store.upsert_plugin(state)
return state
def resume(self, plugin_id: str) -> PluginState:
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
state.updates_paused = False
self.state_store.upsert_plugin(state)
return self.sync_enabled().get(plugin_id) or self._require_state(plugin_id)
def disable(self, plugin_id: str, *, disable_linked_skills: bool) -> PluginState:
if not disable_linked_skills:
raise ValueError("disable_linked_skills confirmation is required")
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
for skill_name in list(state.skills):
self.publisher.disable(skill_name, actor="plugin-manager", reason=f"plugin_disabled:{plugin_id}")
state.skills[skill_name].status = "disabled"
state.enabled = False
state.updates_paused = True
state.status = "disabled"
self.state_store.upsert_plugin(state)
return state
def adopt(self, plugin_id: str, skill_name: str) -> SkillSpec:
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
if skill_name not in state.skills:
raise ValueError(f"Plugin skill binding not found: {plugin_id}/{skill_name}")
spec = self.skill_store.get_skill_spec(skill_name)
if spec is None:
raise ValueError(f"Skill spec not found: {skill_name}")
spec.source_kind = "managed"
spec.status = SkillStatus.ACTIVE.value
spec.updated_at = _utc_now()
marker = f"adopted_from_plugin:{plugin_id}"
if marker not in spec.lineage:
spec.lineage.append(marker)
self.skill_store.write_skill_spec(spec)
del state.skills[skill_name]
if not state.skills:
state.status = "adopted"
state.enabled = False
self.state_store.upsert_plugin(state)
self.publisher._refresh_indexes(skill_name, spec.status)
return spec
def on_skill_published(self, draft: SkillDraft, published: SkillVersion | SkillSpec) -> None:
if draft.proposal_kind != "plugin_skill_update" or not isinstance(published, SkillVersion):
return
plugin_id = str(draft.provenance.get("plugin_id") or "")
skill_name = str(draft.provenance.get("skill_name") or draft.skill_name)
tree_hash = str(draft.provenance.get("new_upstream_tree_hash") or "")
if not plugin_id or not skill_name or not tree_hash:
raise ValueError("Plugin publish acknowledgement is missing provenance")
state = self._require_state(plugin_id)
binding = state.skills.get(skill_name) or PluginSkillBinding()
binding.accepted_upstream_tree_hash = tree_hash
binding.observed_upstream_tree_hash = tree_hash
binding.accepted_beaver_version = published.version
binding.current_beaver_version = published.version
binding.pending_candidate_id = None
binding.status = "synced"
state.skills[skill_name] = binding
state.status = "synced"
self.state_store.upsert_plugin(state)
def _prepare_initial_mirror(
self,
manifest: PluginManifest,
@ -174,6 +245,12 @@ class PluginManager:
)
return prepared
def _require_state(self, plugin_id: str) -> PluginState:
state = self.state_store.get_plugin(plugin_id)
if state is None:
raise ValueError(f"Unknown plugin state: {plugin_id}")
return state
def _sync_plugin(self, state: PluginState, manifest: PluginManifest) -> PluginState:
transaction = PluginSkillTransaction(self.workspace)
try:

View File

@ -0,0 +1,65 @@
"""Deterministic path-level three-way merge for plugin supporting files."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True, slots=True)
class SupportingFileDecision:
path: str
source: str
def to_dict(self) -> dict[str, Any]:
return {"path": self.path, "source": self.source}
@dataclass(frozen=True, slots=True)
class SupportingFileConflict:
path: str
reason: str
def to_dict(self) -> dict[str, Any]:
return {"path": self.path, "reason": self.reason}
@dataclass(frozen=True, slots=True)
class SupportingFileMergePlan:
files: dict[str, SupportingFileDecision] = field(default_factory=dict)
conflicts: list[SupportingFileConflict] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"files": {path: decision.to_dict() for path, decision in sorted(self.files.items())},
"conflicts": [conflict.to_dict() for conflict in self.conflicts],
}
def merge_supporting_file_trees(
*,
base: dict[str, Any],
local: dict[str, Any],
upstream: dict[str, Any],
) -> SupportingFileMergePlan:
decisions: dict[str, SupportingFileDecision] = {}
conflicts: list[SupportingFileConflict] = []
for path in sorted({*base.keys(), *local.keys(), *upstream.keys()} - {"SKILL.md"}):
b = base.get(path)
l = local.get(path)
u = upstream.get(path)
if l == u and l is not None:
decisions[path] = SupportingFileDecision(path=path, source="local")
elif l == b and u is not None:
decisions[path] = SupportingFileDecision(path=path, source="upstream")
elif u == b and l is not None:
decisions[path] = SupportingFileDecision(path=path, source="local")
elif b is None and l is None and u is not None:
decisions[path] = SupportingFileDecision(path=path, source="upstream")
elif b is None and u is None and l is not None:
decisions[path] = SupportingFileDecision(path=path, source="local")
elif b is not None and l is None and u is None:
continue
else:
conflicts.append(SupportingFileConflict(path=path, reason="divergent supporting-file change"))
return SupportingFileMergePlan(files=decisions, conflicts=conflicts)