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