feat(learning): 添加技能学习候选者合成锁定机制

添加了 DraftSynthesisInProgress 和 DraftHasNoChanges 异常来处理并发场景,
确保同一技能学习候选者的合成过程不会重复执行。实现了 claim_learning_candidate_for_synthesis
方法来原子性地锁定候选者进行合成。

fix(web): 为技能草案创建端点添加适当的HTTP状态码

当草案没有变化或正在合成时,现在正确返回409状态码而不是内部错误。

feat(skills): 实现技能修订内容比较以检测无变化情况

添加了 _is_noop_revision 方法来比较基础技能和提议的修订,
如果内容没有实际变化则抛出 NoDraftChanges 异常。

refactor(process): 修复任务证据记录后根运行状态更新逻辑

将任务证据记录事件后的状态从 waiting 更改为 done,并设置 finished_at 时间戳。

feat(tools): 防止在同一运行中重复执行外部写入操作

为邮件发送、日历创建等外部写入工具添加去重机制,避免重复的外部操作。

test: 添加技能学习和工具执行的单元测试

增加测试用例验证并发草案合成、重复外部写入抑制和无变化修订检测等功能。
```
This commit is contained in:
2026-06-16 15:58:42 +08:00
parent f07ce019fe
commit 83d9d8c200
15 changed files with 615 additions and 29 deletions

View File

@ -9,7 +9,7 @@ from .missing_skill import (
MissingSkillDraftResult,
MissingSkillSynthesizer,
)
from .pipeline import SkillLearningPipelineService
from .pipeline import DraftHasNoChanges, DraftSynthesisInProgress, SkillLearningPipelineService
from .preservation import check_preservation
from .replay import ReplayArmRequest, ReplayRunner, ReplayToolExecutor, ReplayToolPolicy, classify_tool_mode
from .service import RunReceiptContext, SkillLearningService
@ -27,6 +27,8 @@ __all__ = [
"MissingSkillDraftResult",
"MissingSkillSynthesizer",
"RunReceiptContext",
"DraftHasNoChanges",
"DraftSynthesisInProgress",
"SkillLearningPipelineService",
"check_preservation",
"ReplayToolExecutor",

View File

@ -9,7 +9,7 @@ from beaver.memory.skills import SkillDraftEvalReport, SkillDraftSafetyReport, S
from beaver.skills.drafts import DraftService
from beaver.skills.learning.eval import SkillDraftEvaluator
from beaver.skills.learning.replay import ReplayRunner
from beaver.skills.learning.service import SkillLearningService
from beaver.skills.learning.service import NoDraftChanges, SkillLearningService
from beaver.skills.learning.safety import SkillDraftSafetyChecker
from beaver.skills.publisher import SkillPublisher
from beaver.skills.reviews import ReviewService
@ -22,6 +22,14 @@ _REJECTABLE_DRAFT_STATUSES = {
}
class DraftSynthesisInProgress(RuntimeError):
"""Raised when another request already claimed the candidate for synthesis."""
class DraftHasNoChanges(RuntimeError):
"""Raised when synthesis produced no effective changes from the base skill."""
class SkillLearningPipelineService:
"""Coordinates candidate -> draft -> review -> publish lifecycle."""
@ -60,8 +68,23 @@ class SkillLearningPipelineService:
candidate_id: str,
*,
provider_bundle: ProviderBundle,
force: bool = False,
) -> SkillDraft:
draft = await self.learning_service.synthesize_draft(candidate_id, provider_bundle)
if not force:
existing = self._draft_for_candidate(candidate_id)
if existing is not None:
return existing
claimed = self.learning_store.claim_learning_candidate_for_synthesis(candidate_id, force=force)
if claimed is None:
existing = self._draft_for_candidate(candidate_id)
if existing is not None:
return existing
raise DraftSynthesisInProgress(f"Draft synthesis is already in progress for candidate: {candidate_id}")
try:
draft = await self.learning_service.synthesize_draft(candidate_id, provider_bundle)
except NoDraftChanges as exc:
self.mark_candidate_superseded(candidate_id, str(exc))
raise DraftHasNoChanges(str(exc)) from exc
self.mark_draft_synthesized(candidate_id, draft)
return draft
@ -71,13 +94,7 @@ class SkillLearningPipelineService:
*,
provider_bundle: ProviderBundle,
) -> SkillDraft:
self.learning_store.transition_learning_candidate(
candidate_id,
"synthesizing",
event_type="draft_synthesis_started",
last_error=None,
)
return await self.synthesize_draft(candidate_id, provider_bundle=provider_bundle)
return await self.synthesize_draft(candidate_id, provider_bundle=provider_bundle, force=True)
def mark_candidate_queued(self, candidate_id: str) -> SkillLearningCandidate:
return self._require_updated(
@ -162,6 +179,12 @@ class SkillLearningPipelineService:
raise ValueError(f"Draft not found: {skill_name}/{draft_id}")
return draft
def _draft_for_candidate(self, candidate_id: str) -> SkillDraft | None:
candidate = self.get_candidate(candidate_id)
if not candidate.draft_skill_name or not candidate.draft_id:
return None
return self.draft_service.get_draft(candidate.draft_skill_name, candidate.draft_id)
def submit_review(
self,
skill_name: str,

View File

@ -20,8 +20,9 @@ from beaver.plugins.tree_merge import merge_supporting_file_trees
from beaver.skills.drafts.service import DraftService
from beaver.skills.learning.evidence import EvidencePacket, EvidenceSelector
from beaver.skills.learning.synthesizer import SkillDraftSynthesizer
from beaver.skills.catalog.utils import parse_frontmatter
from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter
from beaver.skills.specs import SkillActivationReceipt
from beaver.skills.specs.serialization import normalize_frontmatter
@dataclass(slots=True)
@ -30,6 +31,10 @@ class RunReceiptContext:
effect_records: list[SkillEffectRecord] = field(default_factory=list)
class NoDraftChanges(ValueError):
"""Raised when synthesis produces the same effective skill content as the base version."""
class SkillLearningService:
def __init__(
self,
@ -231,13 +236,18 @@ class SkillLearningService:
)
target_skill = candidate.related_skill_names[0]
base_version = candidate.evidence.get("skill_version")
base_skill = self._base_skill_snapshot(target_skill, base_version)
payload = await self.synthesizer.synthesize_revision(
candidate,
packet,
provider,
model,
base_skill=self._base_skill_snapshot(target_skill, base_version),
base_skill=base_skill,
)
if self._is_noop_revision(payload, base_skill):
raise NoDraftChanges(
f"Synthesis produced no changes for {target_skill}/{base_version or 'current'}"
)
return self.draft_service.create_revision_draft(
skill_name=target_skill,
base_version=base_version,
@ -340,6 +350,16 @@ class SkillLearningService:
"tool_hints": list(loaded.version.tool_hints),
}
@staticmethod
def _is_noop_revision(payload: dict[str, Any], base_skill: dict[str, Any] | None) -> bool:
if base_skill is None:
return False
base_frontmatter = normalize_frontmatter(dict(base_skill.get("frontmatter") or {}))
proposed_frontmatter = normalize_frontmatter(dict(payload.get("frontmatter") or {}))
base_body = _normalize_skill_body(str(base_skill.get("content") or ""))
proposed_body = _normalize_skill_body(str(payload.get("content") or ""))
return base_frontmatter == proposed_frontmatter and base_body == proposed_body
def _merged_base_skill_snapshot(self, skill_names: list[str]) -> dict[str, Any] | None:
snapshots = [
snapshot
@ -602,6 +622,10 @@ class SkillLearningService:
return parsed.astimezone(timezone.utc)
def _normalize_skill_body(content: str) -> str:
return "\n".join(line.rstrip() for line in strip_frontmatter(content).strip().splitlines()).strip()
def _digest_map(root: Path) -> dict[str, dict[str, Any]]:
digest = hash_plugin_skill_tree(root)
return {

View File

@ -9,7 +9,7 @@ from typing import Callable
from beaver.engine.providers import ProviderBundle
from beaver.memory.skills import SkillLearningCandidate
from beaver.skills.learning.pipeline import SkillLearningPipelineService
from beaver.skills.learning.pipeline import DraftHasNoChanges, SkillLearningPipelineService
from beaver.skills.learning.replay import ReplayRunner
@ -114,13 +114,13 @@ class SkillLearningWorker:
if self._has_active_draft(candidate):
self.pipeline.mark_candidate_superseded(candidate.candidate_id, "active draft already exists for this skill")
return False
self.pipeline.mark_candidate_queued(candidate.candidate_id)
self.pipeline.mark_candidate_synthesizing(candidate.candidate_id)
draft = await self.pipeline.synthesize_draft(
candidate.candidate_id,
provider_bundle=self.provider_bundle_factory(),
)
self.pipeline.mark_draft_synthesized(candidate.candidate_id, draft)
try:
draft = await self.pipeline.synthesize_draft(
candidate.candidate_id,
provider_bundle=self.provider_bundle_factory(),
)
except DraftHasNoChanges:
return False
safety = self.pipeline.check_safety(draft.skill_name, draft.draft_id)
if not safety.passed or safety.risk_level == "critical":
return True