diff --git a/src/autoskillit/workspace/AGENTS.md b/src/autoskillit/workspace/AGENTS.md index af2b4cf43..b4571e038 100644 --- a/src/autoskillit/workspace/AGENTS.md +++ b/src/autoskillit/workspace/AGENTS.md @@ -12,7 +12,7 @@ IL-1 workspace management — clone lifecycle, worktrees, skill resolution. | `_clone_detect.py` | `detect_*` helpers + `RUNS_DIR` + `classify_remote_url` | | `_clone_remote.py` | `CloneSourceResolution` + probe/isolate remotes | | `session_skills.py` | Per-session ephemeral skill dirs; subset filtering | -| `skill_capabilities.py` | Semantic classification and validation of skill capability evidence | +| `skill_capabilities.py` | Semantic classification, bounded process-local evidence memoization, and capability validation | | `skill_format.py` | SKILL.md frontmatter validation per agentskills.io spec | | `skill_projection.py` | Agent-safe projections of typed skill machine contracts | | `_projection_cache.py` | Projection asset inventory, cache-key record, and orphan sweep | @@ -44,3 +44,7 @@ Clone paths live under `RUNS_DIR` (resolved by `_clone_detect.py`). `clone_regis coordinates deferred cleanup across concurrent pipeline sessions using file-based locking. `session_skills.py` builds per-session ephemeral copies of the bundled skill set so that headless sessions can use a filtered subset without polluting the installed package. + +`skill_capabilities.py` owns a process-local, weighted LRU keyed by exact canonical +content and normalized logical skill name. The cache bounds resident entries and +accounted payload bytes while coordinating concurrent scans outside its lock. diff --git a/src/autoskillit/workspace/skill_capabilities.py b/src/autoskillit/workspace/skill_capabilities.py index 751115f63..7be2a369e 100644 --- a/src/autoskillit/workspace/skill_capabilities.py +++ b/src/autoskillit/workspace/skill_capabilities.py @@ -7,8 +7,10 @@ from __future__ import annotations -from dataclasses import dataclass +from collections import OrderedDict +from dataclasses import dataclass, field from functools import cache +from threading import Event, RLock from typing import TYPE_CHECKING, Literal import regex as re @@ -21,6 +23,15 @@ CapabilityActor = Literal["self", "parent", "external"] CapabilityDirection = Literal["outbound", "inbound", "descriptive"] CapabilitySourceClassification = Literal["executable", "artifact"] +_SkillCapabilityEvidenceKey = tuple[str, str] + +# Accounted resident payload includes exact key strings, evidence source strings, +# and a stable policy charge per immutable evidence record. Entry count bounds +# the remaining fixed per-entry overhead. +_SKILL_CAPABILITY_EVIDENCE_RECORD_WEIGHT_BYTES = 192 +_SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_ENTRIES = 256 +_SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_BYTES = 16 * 1024 * 1024 +_SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_INPUT_BYTES = 512 * 1024 @dataclass(frozen=True, slots=True) @@ -67,6 +78,189 @@ def valid(self) -> bool: return not self.missing and not self.unsupported +@dataclass(frozen=True, slots=True) +class _SkillCapabilityEvidenceCacheEntry: + evidence: tuple[SkillCapabilityEvidence, ...] + weight_bytes: int + + +@dataclass(frozen=True, slots=True) +class _SkillCapabilityEvidenceCacheInfo: + max_entries: int + max_bytes: int + max_input_bytes: int + entry_count: int + weight_bytes: int + inflight_builds: int + inflight_waiters: int + + +@dataclass(slots=True) +class _SkillCapabilityEvidenceBuildState: + event: Event = field(default_factory=Event) + result: tuple[SkillCapabilityEvidence, ...] | None = None + error: BaseException | None = None + + +class _SkillCapabilityEvidenceCache: + """Thread-safe weighted LRU with generation-scoped single-flight state.""" + + def __init__( + self, + *, + max_entries: int, + max_bytes: int, + max_input_bytes: int, + ) -> None: + for field_name, value in ( + ("max_entries", max_entries), + ("max_bytes", max_bytes), + ("max_input_bytes", max_input_bytes), + ): + if value <= 0: + raise ValueError(f"{field_name} must be positive") + + self._max_entries = max_entries + self._max_bytes = max_bytes + self._max_input_bytes = max_input_bytes + self._entries: OrderedDict[ + _SkillCapabilityEvidenceKey, + _SkillCapabilityEvidenceCacheEntry, + ] = OrderedDict() + self._inflight: dict[ + _SkillCapabilityEvidenceKey, + _SkillCapabilityEvidenceBuildState, + ] = {} + self._weight_bytes = 0 + self._inflight_waiters = 0 + self._lock = RLock() + + @property + def max_input_bytes(self) -> int: + return self._max_input_bytes + + def info(self) -> _SkillCapabilityEvidenceCacheInfo: + with self._lock: + return _SkillCapabilityEvidenceCacheInfo( + max_entries=self._max_entries, + max_bytes=self._max_bytes, + max_input_bytes=self._max_input_bytes, + entry_count=len(self._entries), + weight_bytes=self._weight_bytes, + inflight_builds=len(self._inflight), + inflight_waiters=self._inflight_waiters, + ) + + def _new_build_state(self) -> _SkillCapabilityEvidenceBuildState: + return _SkillCapabilityEvidenceBuildState() + + def _lookup_or_register( + self, + key: _SkillCapabilityEvidenceKey, + ) -> tuple[ + tuple[SkillCapabilityEvidence, ...] | None, + _SkillCapabilityEvidenceBuildState | None, + bool, + ]: + with self._lock: + entry = self._entries.get(key) + if entry is not None: + self._entries.move_to_end(key) + return entry.evidence, None, False + + state = self._inflight.get(key) + if state is not None: + self._inflight_waiters += 1 + return None, state, False + + state = self._new_build_state() + self._inflight[key] = state + return None, state, True + + def _wait_for_build( + self, + key: _SkillCapabilityEvidenceKey, + state: _SkillCapabilityEvidenceBuildState, + ) -> tuple[SkillCapabilityEvidence, ...]: + try: + state.event.wait() + except BaseException: + with self._lock: + self._inflight_waiters -= 1 + raise + + with self._lock: + self._inflight_waiters -= 1 + if state.error is not None: + raise RuntimeError( + "Capability evidence build failed in another thread" + ) from state.error + result = state.result + if result is None: + raise RuntimeError("Capability evidence build completed without a result") + entry = self._entries.get(key) + if entry is not None and entry.evidence is result: + self._entries.move_to_end(key) + return result + + def _evict_if_needed_locked(self) -> None: + while len(self._entries) > self._max_entries or self._weight_bytes > self._max_bytes: + _, entry = self._entries.popitem(last=False) + self._weight_bytes -= entry.weight_bytes + + def _publish_failure( + self, + key: _SkillCapabilityEvidenceKey, + state: _SkillCapabilityEvidenceBuildState, + error: BaseException, + ) -> None: + with self._lock: + state.result = None + state.error = error + if self._inflight.get(key) is state: + del self._inflight[key] + state.event.set() + + def _complete_build( + self, + key: _SkillCapabilityEvidenceKey, + state: _SkillCapabilityEvidenceBuildState, + result: tuple[SkillCapabilityEvidence, ...], + weight_bytes: int, + ) -> tuple[SkillCapabilityEvidence, ...]: + with self._lock: + resident_mutated = False + try: + if weight_bytes <= self._max_bytes: + resident_mutated = True + previous = self._entries.pop(key, None) + if previous is not None: + self._weight_bytes -= previous.weight_bytes + self._entries[key] = _SkillCapabilityEvidenceCacheEntry( + evidence=result, + weight_bytes=weight_bytes, + ) + self._weight_bytes += weight_bytes + self._evict_if_needed_locked() + + state.result = result + state.error = None + if self._inflight.get(key) is state: + del self._inflight[key] + state.event.set() + except BaseException as error: + if resident_mutated: + self._entries.clear() + self._weight_bytes = 0 + state.result = None + state.error = error + if self._inflight.get(key) is state: + del self._inflight[key] + state.event.set() + raise + return result + + @dataclass(frozen=True, slots=True) class _SourceLine: number: int @@ -186,6 +380,41 @@ def _frontmatter_skill_name(content: str) -> str: return match.group(1).strip() if match else "" +def _normalize_skill_capability_name(content: str, skill_name: str | None) -> str: + return skill_name or _frontmatter_skill_name(content) + + +def _retained_string_weight_bytes(value: str) -> int: + return len(value.encode("utf-8", errors="surrogatepass")) + + +def _skill_capability_evidence_input_weight_bytes( + content: str, + effective_skill_name: str, +) -> int: + return _retained_string_weight_bytes(content) + _retained_string_weight_bytes( + effective_skill_name + ) + + +def _skill_capability_evidence_entry_weight_bytes( + input_weight_bytes: int, + evidence: tuple[SkillCapabilityEvidence, ...], +) -> int: + return ( + input_weight_bytes + + sum(_retained_string_weight_bytes(item.source) for item in evidence) + + len(evidence) * _SKILL_CAPABILITY_EVIDENCE_RECORD_WEIGHT_BYTES + ) + + +_SKILL_CAPABILITY_EVIDENCE_CACHE = _SkillCapabilityEvidenceCache( + max_entries=_SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_ENTRIES, + max_bytes=_SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_BYTES, + max_input_bytes=_SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_INPUT_BYTES, +) + + def _source_lines(body: str) -> tuple[_SourceLine, ...]: """Mark frontmatter, constraint blocks, and documentary fences non-executable.""" result: list[_SourceLine] = [] @@ -411,16 +640,10 @@ def _is_cross_skill_ref(text: str, skill_name: str) -> bool: return "run_skill" in lower and "/autoskillit:" in lower -def classify_skill_capability_evidence( +def _scan_skill_capability_evidence_uncached( content: str, - skill_name: str | None = None, + effective_skill_name: str, ) -> tuple[SkillCapabilityEvidence, ...]: - """Classify all recognizable capability occurrences in ``content``. - - Documentary occurrences are retained as ``artifact`` evidence so callers - can explain why a declaration was rejected without treating it as genuine. - """ - effective_skill_name = skill_name or _frontmatter_skill_name(content) lines = _source_lines(content) found: list[SkillCapabilityEvidence] = [] seen: set[tuple[str, tuple[int, int], str]] = set() @@ -478,6 +701,56 @@ def add(capability: str, source_lines: tuple[_SourceLine, ...]) -> None: return tuple(sorted(found, key=lambda item: (item.source_span, item.capability))) +def classify_skill_capability_evidence( + content: str, + skill_name: str | None = None, +) -> tuple[SkillCapabilityEvidence, ...]: + """Classify all recognizable capability occurrences in ``content``. + + Documentary occurrences are retained as ``artifact`` evidence so callers + can explain why a declaration was rejected without treating it as genuine. + """ + effective_skill_name = _normalize_skill_capability_name(content, skill_name) + evidence_cache = _SKILL_CAPABILITY_EVIDENCE_CACHE + scanner = _scan_skill_capability_evidence_uncached + if len(content) + len(effective_skill_name) > evidence_cache.max_input_bytes: + return scanner(content, effective_skill_name) + + input_weight_bytes = _skill_capability_evidence_input_weight_bytes( + content, + effective_skill_name, + ) + if input_weight_bytes > evidence_cache.max_input_bytes: + return scanner(content, effective_skill_name) + + hash(content) + hash(effective_skill_name) + key = (content, effective_skill_name) + resident, state, is_builder = evidence_cache._lookup_or_register(key) + if resident is not None: + return resident + if state is None: + raise RuntimeError("Capability evidence cache returned no build state") + if not is_builder: + return evidence_cache._wait_for_build(key, state) + + try: + result = scanner(content, effective_skill_name) + completed_weight_bytes = _skill_capability_evidence_entry_weight_bytes( + input_weight_bytes, + result, + ) + except BaseException as error: + evidence_cache._publish_failure(key, state, error) + raise + return evidence_cache._complete_build( + key, + state, + result, + completed_weight_bytes, + ) + + def detect_skill_capabilities( content: str, skill_name: str | None = None, diff --git a/tests/_test_filter.py b/tests/_test_filter.py index f84ca1675..81329115f 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -723,6 +723,7 @@ class ImportContext(enum.StrEnum): "recipe/test_recipe_backend_composition_matrix.py", "recipe/test_recipe_composition_vacuous_gate.py", "recipe/test_rules_backend_compat.py", + "recipe/test_skill_capability_cache_integration.py", "recipe/test_rules_skill_content.py", "recipe/test_rules_stamp_ownership.py", # recipe transitive entries (exercise workspace via deferred imports in diff --git a/tests/arch/test_layer_enforcement.py b/tests/arch/test_layer_enforcement.py index 93a777ab6..0cf59d08f 100644 --- a/tests/arch/test_layer_enforcement.py +++ b/tests/arch/test_layer_enforcement.py @@ -1664,6 +1664,9 @@ def test_default_classes_only_instantiated_inside_factory_or_allowlist() -> None # recipe tests — recipe layer is IL-2 and may use workspace (IL-1 sibling) or config (IL-1) "tests/recipe/test_rules_inputs.py": frozenset({"autoskillit.config"}), "tests/recipe/test_contracts.py": frozenset({"autoskillit.workspace"}), + "tests/recipe/test_skill_capability_cache_integration.py": frozenset( + {"autoskillit.workspace"} + ), "tests/recipe/test_rules_skill_content.py": frozenset({"autoskillit.workspace"}), "tests/recipe/test_rules_backend_compat.py": frozenset( {"autoskillit.server", "autoskillit.workspace"} diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 6b8c87a32..cbac56778 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -106,6 +106,7 @@ def _get_call_func_name(node: ast.Call) -> str | None: "_step_context", # core/_step_context.py: current_step_name, current_order_id ContextVars "_api_cache", # recipe/_api_cache.py: _LOAD_CACHE = LoadCache() "_contracts_manifest", # recipe/_contracts_manifest.py: _MANIFEST_CACHE = YamlFileCache() + "skill_capabilities", # workspace/skill_capabilities.py: bounded evidence cache "methodology_venue_appendix", # recipe/methodology_venue_appendix.py: _ML_SUB_AREA_CACHE "rules_blocks", # recipe/rules/rules_blocks.py: _BUDGETS_CACHE = YamlFileCache() "rules_phoropter_adjacency", # recipe/rules/rules_phoropter_adjacency.py: _PREFIXES_CACHE diff --git a/tests/cli/test_doctor.py b/tests/cli/test_doctor.py index 3d1aa7de1..6f0202c3b 100644 --- a/tests/cli/test_doctor.py +++ b/tests/cli/test_doctor.py @@ -1175,6 +1175,7 @@ def test_check_source_version_drift_ok_outside_source_repo( """GIT_VCS install with empty cache reports OK (no drift observable).""" from autoskillit.cli._install_info import InstallInfo, InstallType from autoskillit.cli.doctor import _check_source_version_drift + from autoskillit.cli.update import _update_checks as update_checks_module from autoskillit.core import Severity info = InstallInfo( @@ -1187,7 +1188,8 @@ def test_check_source_version_drift_ok_outside_source_repo( monkeypatch.setattr("autoskillit.cli._install_info.detect_install", lambda: info) # Simulate empty cache and no source repo: resolve returns None monkeypatch.setattr( - "autoskillit.cli.update._update_checks.resolve_reference_sha", + update_checks_module, + "resolve_reference_sha", lambda info, home, **kw: None, ) @@ -1223,6 +1225,7 @@ def test_check_source_version_drift_ok_for_pinned_sha( """When requested_revision == commit_id, resolve_reference_sha short-circuits → no drift.""" from autoskillit.cli._install_info import InstallInfo, InstallType from autoskillit.cli.doctor import _check_source_version_drift + from autoskillit.cli.update import _update_checks as update_checks_module from autoskillit.core import Severity sha = "abcdef1234567890abcdef1234567890" @@ -1236,7 +1239,9 @@ def test_check_source_version_drift_ok_for_pinned_sha( monkeypatch.setattr("autoskillit.cli._install_info.detect_install", lambda: info) # When requested_revision == commit_id, resolve_reference_sha returns commit_id monkeypatch.setattr( - "autoskillit.cli.update._update_checks.resolve_reference_sha", lambda info, home, **kw: sha + update_checks_module, + "resolve_reference_sha", + lambda info, home, **kw: sha, ) result = _check_source_version_drift(home=tmp_path) @@ -1249,6 +1254,7 @@ def test_check_source_version_drift_ok_when_cache_empty( """When SHA cannot be resolved (network/cache miss), doctor reports OK.""" from autoskillit.cli._install_info import InstallInfo, InstallType from autoskillit.cli.doctor import _check_source_version_drift + from autoskillit.cli.update import _update_checks as update_checks_module from autoskillit.core import Severity info = InstallInfo( @@ -1260,7 +1266,8 @@ def test_check_source_version_drift_ok_when_cache_empty( ) monkeypatch.setattr("autoskillit.cli._install_info.detect_install", lambda: info) monkeypatch.setattr( - "autoskillit.cli.update._update_checks.resolve_reference_sha", + update_checks_module, + "resolve_reference_sha", lambda info, home, **kw: None, ) @@ -1278,6 +1285,7 @@ def test_check_source_version_drift_warning_on_drift( """When cache has a different reference SHA than installed, reports WARNING with short SHAs.""" from autoskillit.cli._install_info import InstallInfo, InstallType from autoskillit.cli.doctor import _check_source_version_drift + from autoskillit.cli.update import _update_checks as update_checks_module from autoskillit.core import Severity installed_sha = "installed123abc" @@ -1292,7 +1300,8 @@ def test_check_source_version_drift_warning_on_drift( ) monkeypatch.setattr("autoskillit.cli._install_info.detect_install", lambda: info) monkeypatch.setattr( - "autoskillit.cli.update._update_checks.resolve_reference_sha", + update_checks_module, + "resolve_reference_sha", lambda info, home, **kw: ref_sha, ) diff --git a/tests/recipe/AGENTS.md b/tests/recipe/AGENTS.md index 2a94ac4a9..d499f0a32 100644 --- a/tests/recipe/AGENTS.md +++ b/tests/recipe/AGENTS.md @@ -222,6 +222,7 @@ Recipe I/O, validation, semantic rules, schema, and bundled recipe tests. | `test_schema.py` | Tests for Recipe, RecipeStep, and DataFlowWarning schema | | `test_skip_inviting_notes.py` | Tests that bundled recipe optional steps have no skip-inviting note phrases | | `test_skill_contract_completeness.py` | Tests for SKILL.md to skill_contracts.yaml output completeness | +| `test_skill_capability_cache_integration.py` | Tests recipe-validation integration with the workspace capability evidence cache | | `test_skill_emit_consistency.py` | Tests for skill emit consistency in recipe steps | | `test_skill_worktree_patterns.py` | Tests that SKILL.md files do not use fragile relative worktree path patterns | | `test_silent_type_convention.py` | Tests for silent-type-convention.md documentation | diff --git a/tests/recipe/test_skill_capability_cache_integration.py b/tests/recipe/test_skill_capability_cache_integration.py new file mode 100644 index 000000000..bef760782 --- /dev/null +++ b/tests/recipe/test_skill_capability_cache_integration.py @@ -0,0 +1,109 @@ +"""Recipe-validation integration contracts for capability evidence caching.""" + +from __future__ import annotations + +import hashlib +import json +from collections import Counter + +import pytest + +import autoskillit.workspace.skill_capabilities as skill_capabilities +from autoskillit.recipe.io import builtin_recipes_dir, load_recipe +from autoskillit.recipe.validator import run_semantic_rules + +pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] + + +def test_bundled_recipe_validation_reuses_capability_evidence_cache( + monkeypatch, + record_property, +) -> None: + cache = skill_capabilities._SkillCapabilityEvidenceCache( + max_entries=skill_capabilities._SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_ENTRIES, + max_bytes=skill_capabilities._SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_BYTES, + max_input_bytes=skill_capabilities._SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_INPUT_BYTES, + ) + monkeypatch.setattr( + skill_capabilities, + "_SKILL_CAPABILITY_EVIDENCE_CACHE", + cache, + ) + public_keys: list[tuple[str, str]] = [] + scan_keys: list[tuple[str, str]] = [] + results_by_key: dict[tuple[str, str], tuple] = {} + normalized_findings: list[dict[str, object]] = [] + original_classifier = skill_capabilities.classify_skill_capability_evidence + original_scanner = skill_capabilities._scan_skill_capability_evidence_uncached + + def recording_classifier( + content: str, + skill_name: str | None = None, + ): + effective_name = skill_capabilities._normalize_skill_capability_name( + content, + skill_name, + ) + key = (content, effective_name) + public_keys.append(key) + result = original_classifier(content, effective_name) + results_by_key[key] = result + return result + + def recording_scanner(content: str, effective_name: str): + scan_keys.append((content, effective_name)) + return original_scanner(content, effective_name) + + monkeypatch.setattr( + skill_capabilities, + "classify_skill_capability_evidence", + recording_classifier, + ) + monkeypatch.setattr( + skill_capabilities, + "_scan_skill_capability_evidence_uncached", + recording_scanner, + ) + + for yaml_path in sorted(builtin_recipes_dir().glob("*.yaml")): + findings = run_semantic_rules(load_recipe(yaml_path)) + normalized_findings.append( + { + "recipe": yaml_path.name, + "findings": [finding.to_dict() for finding in findings], + } + ) + + canonical_findings = json.dumps( + normalized_findings, + sort_keys=True, + separators=(",", ":"), + ) + unique_public_keys = set(public_keys) + scan_counts = Counter(scan_keys) + info = cache.info() + assert len(public_keys) > len(scan_keys) + assert set(scan_keys) == unique_public_keys + assert scan_counts == Counter({key: 1 for key in unique_public_keys}) + for content, effective_name in unique_public_keys: + input_weight = skill_capabilities._skill_capability_evidence_input_weight_bytes( + content, + effective_name, + ) + completed_weight = skill_capabilities._skill_capability_evidence_entry_weight_bytes( + input_weight, + results_by_key[(content, effective_name)], + ) + assert input_weight <= info.max_input_bytes + assert completed_weight <= info.max_bytes + assert info.entry_count == len(unique_public_keys) + assert info.inflight_builds == 0 + assert info.inflight_waiters == 0 + record_property("classifier_public_calls", len(public_keys)) + record_property("classifier_unique_semantic_inputs", len(unique_public_keys)) + record_property("classifier_underlying_scans", len(scan_keys)) + record_property("semantic_findings_json", canonical_findings) + record_property( + "semantic_findings_fingerprint", + hashlib.sha256(canonical_findings.encode()).hexdigest(), + ) diff --git a/tests/workspace/AGENTS.md b/tests/workspace/AGENTS.md index 19f52377d..631074280 100644 --- a/tests/workspace/AGENTS.md +++ b/tests/workspace/AGENTS.md @@ -26,6 +26,7 @@ Workspace cleanup, clone lifecycle, session skills, and worktree tests. | `test_session_skills_provider.py` | Tests for unified projection and exact-catalog materialization | | `test_session_skills_stale_path.py` | Tests for validate_session_exists() and cleanup_stale() structured logging | | `test_skill_content_substitution.py` | Tests for SkillsDirectoryProvider.get_skill_content placeholder substitution | +| `test_skill_capabilities_cache.py` | Bounded semantic-evidence cache identity, eviction, admission, and concurrency contracts | | `test_skill_format.py` | Unit tests for skill frontmatter validation functions | | `test_skills.py` | Tests for skill resolution hierarchy | | `test_worktree.py` | Worktree tests | diff --git a/tests/workspace/test_project_local_overrides.py b/tests/workspace/test_project_local_overrides.py index 14c58b235..527d7768f 100644 --- a/tests/workspace/test_project_local_overrides.py +++ b/tests/workspace/test_project_local_overrides.py @@ -260,6 +260,87 @@ def test_resolve_effective_observes_new_override_without_cross_dispatch_cache( assert second.execution_role.value == "orchestrator" +def test_project_local_rewrite_reclassifies_with_process_cache(tmp_path, monkeypatch) -> None: + """Changed canonical bytes must bypass a resident semantic classification.""" + import hashlib + + import autoskillit.workspace.skill_capabilities as capability_module + from autoskillit.workspace.skills import DefaultSkillResolver + + cache = capability_module._SkillCapabilityEvidenceCache( + max_entries=capability_module._SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_ENTRIES, + max_bytes=capability_module._SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_BYTES, + max_input_bytes=(capability_module._SKILL_CAPABILITY_EVIDENCE_CACHE_MAX_INPUT_BYTES), + ) + monkeypatch.setattr( + capability_module, + "_SKILL_CAPABILITY_EVIDENCE_CACHE", + cache, + ) + scan_keys: list[tuple[str, str]] = [] + original_scanner = capability_module._scan_skill_capability_evidence_uncached + + def recording_scanner(content: str, effective_name: str): + scan_keys.append((content, effective_name)) + return original_scanner(content, effective_name) + + monkeypatch.setattr( + capability_module, + "_scan_skill_capability_evidence_uncached", + recording_scanner, + ) + project = tmp_path / "project" + skill_root = project / ".claude" / "skills" + skill_path = _write_effective_skill( + skill_root, + "cache-rewrite-target", + capabilities=("git_metadata_write",), + execution_role="session", + body='git commit -m "first sentinel"', + ) + resolver = DefaultSkillResolver() + + first = resolver.resolve_effective("cache-rewrite-target", project) + + assert first is not None + assert first.invalid_reason is None + first_evidence = capability_module.classify_skill_capability_evidence( + first.canonical_content, + first.name, + ) + assert first_evidence[0].source == 'git commit -m "first sentinel"' + assert first.canonical_digest == hashlib.sha256(skill_path.read_bytes()).hexdigest() + + _write_effective_skill( + skill_root, + "cache-rewrite-target", + capabilities=("agent_model",), + execution_role="session", + body='git commit -m "second sentinel"', + ) + second = resolver.resolve_effective("cache-rewrite-target", project) + + assert second is not None + assert second is not first + assert second.canonical_content != first.canonical_content + assert second.canonical_digest != first.canonical_digest + assert second.canonical_digest == hashlib.sha256(skill_path.read_bytes()).hexdigest() + second_evidence = capability_module.classify_skill_capability_evidence( + second.canonical_content, + second.name, + ) + assert second_evidence[0].source == 'git commit -m "second sentinel"' + assert second_evidence[0].source_span == (7, 7) + assert second.invalid_reason is not None + assert "missing declaration for 'git_metadata_write'" in second.invalid_reason + assert "second sentinel" in second.invalid_reason + assert "first sentinel" not in second.invalid_reason + assert scan_keys == [ + (first.canonical_content, "cache-rewrite-target"), + (second.canonical_content, "cache-rewrite-target"), + ] + + def test_resolve_effective_observes_removed_override_and_falls_back(tmp_path, monkeypatch): """Removing a winning override exposes the lower-priority source on the next lookup.""" from autoskillit.workspace.skills import DefaultSkillResolver diff --git a/tests/workspace/test_skill_capabilities_cache.py b/tests/workspace/test_skill_capabilities_cache.py new file mode 100644 index 000000000..e1ab2fdae --- /dev/null +++ b/tests/workspace/test_skill_capabilities_cache.py @@ -0,0 +1,779 @@ +"""Bounded memoization contracts for semantic skill capability evidence.""" + +from __future__ import annotations + +from collections import Counter +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import FrozenInstanceError +from threading import Event, Lock +from time import monotonic, sleep + +import pytest + +import autoskillit.workspace.skill_capabilities as capabilities + +pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] + + +def _document(name: str, body: str) -> str: + return f"---\nname: {name}\ndescription: Cache fixture.\n---\n{body}\n" + + +@pytest.fixture +def evidence_cache(monkeypatch): + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=32, + max_bytes=1024 * 1024, + max_input_bytes=64 * 1024, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + return cache + + +@pytest.fixture +def scan_calls(monkeypatch): + calls: list[tuple[str, str]] = [] + original = capabilities._scan_skill_capability_evidence_uncached + + def recording_scanner(content: str, effective_name: str): + calls.append((content, effective_name)) + return original(content, effective_name) + + monkeypatch.setattr( + capabilities, + "_scan_skill_capability_evidence_uncached", + recording_scanner, + ) + return calls + + +def _wait_for_cache_info(cache, predicate, *, timeout: float = 2.0) -> None: + deadline = monotonic() + timeout + while monotonic() < deadline: + if predicate(cache.info()): + return + sleep(0.001) + pytest.fail(f"cache state did not converge before timeout: {cache.info()!r}") + + +@pytest.mark.parametrize("field", ("max_entries", "max_bytes", "max_input_bytes")) +@pytest.mark.parametrize("invalid", (0, -1)) +def test_constructor_rejects_nonpositive_limits(field: str, invalid: int) -> None: + limits = { + "max_entries": 1, + "max_bytes": 1, + "max_input_bytes": 1, + } + limits[field] = invalid + + with pytest.raises(ValueError, match=field): + capabilities._SkillCapabilityEvidenceCache(**limits) + + +def test_resident_semantic_input_scans_once_and_reuses_tuple(evidence_cache, scan_calls) -> None: + content = _document("resident", 'git commit -m "resident"') + + first = capabilities.classify_skill_capability_evidence(content, "resident") + second = capabilities.classify_skill_capability_evidence(content, "resident") + + assert first is second + assert len(scan_calls) == 1 + assert evidence_cache.info().entry_count == 1 + + +def test_cache_info_is_an_immutable_policy_snapshot(evidence_cache) -> None: + info = evidence_cache.info() + + with pytest.raises(FrozenInstanceError): + info.entry_count = 0 # type: ignore[misc] + + +def test_empty_evidence_tuple_is_a_cache_hit(evidence_cache, scan_calls) -> None: + content = _document("empty", "Pure prose without a capability operation.") + + first = capabilities.classify_skill_capability_evidence(content) + second = capabilities.classify_skill_capability_evidence(content) + + assert first == () + assert first is second + assert len(scan_calls) == 1 + assert evidence_cache.info().entry_count == 1 + + +def test_content_and_effective_name_are_independent_key_dimensions( + evidence_cache, scan_calls +) -> None: + content = _document("alpha", "Use the `/autoskillit:alpha` skill.") + + self_reference = capabilities.classify_skill_capability_evidence(content, "alpha") + cross_reference = capabilities.classify_skill_capability_evidence(content, "beta") + edited_content = content.replace("autoskillit:alpha", "autoskillit:gamma") + edited = capabilities.classify_skill_capability_evidence(edited_content, "beta") + + assert self_reference == () + assert [ + ( + item.capability, + item.actor, + item.direction, + item.classification, + item.source_span, + item.source, + ) + for item in cross_reference + ] == [ + ( + "cross_skill_ref", + "self", + "outbound", + "executable", + (5, 5), + "Use the `/autoskillit:alpha` skill.", + ) + ] + assert edited[0].source == "Use the `/autoskillit:gamma` skill." + assert edited[0].source_span == (5, 5) + assert len(scan_calls) == 3 + assert evidence_cache.info().entry_count == 3 + + +def test_falsey_and_frontmatter_names_share_one_entry(evidence_cache, scan_calls) -> None: + content = _document("normalized", "Use the `/autoskillit:other` skill.") + + omitted = capabilities.classify_skill_capability_evidence(content) + empty = capabilities.classify_skill_capability_evidence(content, "") + explicit = capabilities.classify_skill_capability_evidence(content, "normalized") + + assert omitted is empty is explicit + assert len(scan_calls) == 1 + assert evidence_cache.info().entry_count == 1 + + +@pytest.mark.parametrize( + ("content", "skill_name"), + ( + (_document("surrogate", 'git commit -m "cold\ud800warm"'), "surrogate"), + (_document("surrogate", "Use the `/autoskillit:other` skill."), "logical\ud800"), + ), +) +def test_lone_surrogates_are_total_on_cold_and_warm_calls( + evidence_cache, + scan_calls, + content: str, + skill_name: str, +) -> None: + first = capabilities.classify_skill_capability_evidence(content, skill_name) + second = capabilities.classify_skill_capability_evidence(content, skill_name) + + assert first is second + assert len(scan_calls) == 1 + assert evidence_cache.info().weight_bytes > 0 + + +def test_cold_and_warm_mixed_corpus_preserves_complete_evidence( + evidence_cache, scan_calls +) -> None: + content = _document( + "mixed", + "\n".join( + ( + "## Step 1", + 'git commit -m "genuine"', + "## Examples", + 'gh issue edit 42 --body-file "artifact.md"', + ) + ), + ) + + cold = capabilities.classify_skill_capability_evidence(content, "mixed") + warm = capabilities.classify_skill_capability_evidence(content, "mixed") + + assert warm is cold + assert tuple( + ( + item.capability, + item.actor, + item.direction, + item.classification, + item.source_span, + item.source, + ) + for item in cold + ) == ( + ( + "git_metadata_write", + "self", + "outbound", + "executable", + (6, 6), + 'git commit -m "genuine"', + ), + ( + "github_api_write", + "external", + "inbound", + "artifact", + (8, 8), + 'gh issue edit 42 --body-file "artifact.md"', + ), + ) + assert capabilities.detect_skill_capabilities(content, "mixed") == frozenset( + {"git_metadata_write"} + ) + with pytest.raises(FrozenInstanceError): + cold[0].source = "mutated" # type: ignore[misc] + assert len(scan_calls) == 1 + + +def test_entry_count_lru_refresh_and_eviction_are_deterministic(monkeypatch, scan_calls) -> None: + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=2, + max_bytes=1024 * 1024, + max_input_bytes=64 * 1024, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + documents = { + name: _document(name, f"Plain content for {name}.") for name in ("alpha", "beta", "gamma") + } + + alpha = capabilities.classify_skill_capability_evidence(documents["alpha"]) + capabilities.classify_skill_capability_evidence(documents["beta"]) + assert capabilities.classify_skill_capability_evidence(documents["alpha"]) is alpha + capabilities.classify_skill_capability_evidence(documents["gamma"]) + capabilities.classify_skill_capability_evidence(documents["beta"]) + + counts = Counter(name for _, name in scan_calls) + assert counts == Counter({"beta": 2, "alpha": 1, "gamma": 1}) + assert cache.info().entry_count == 2 + assert cache.info().weight_bytes <= cache.info().max_bytes + + +def test_exact_aggregate_byte_boundary_and_one_byte_overflow(monkeypatch, scan_calls) -> None: + content = _document("boundary", "No recognized capability.") + key_weight = capabilities._skill_capability_evidence_input_weight_bytes(content, "boundary") + exact = capabilities._SkillCapabilityEvidenceCache( + max_entries=2, + max_bytes=key_weight, + max_input_bytes=key_weight, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", exact) + + first = capabilities.classify_skill_capability_evidence(content, "boundary") + assert exact.info().weight_bytes == key_weight + assert capabilities.classify_skill_capability_evidence(content, "boundary") is first + + overflow = capabilities._SkillCapabilityEvidenceCache( + max_entries=2, + max_bytes=key_weight - 1, + max_input_bytes=key_weight, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", overflow) + capabilities.classify_skill_capability_evidence(content, "boundary") + capabilities.classify_skill_capability_evidence(content, "boundary") + + assert overflow.info().entry_count == 0 + assert overflow.info().weight_bytes == 0 + assert len(scan_calls) == 3 + + +def test_non_ascii_input_over_byte_limit_bypasses_cache(monkeypatch, scan_calls) -> None: + effective_name = "unicode" + content = _document( + effective_name, + 'éééééééééééééééé\ngit commit -m "unicode"', + ) + character_weight = len(content) + len(effective_name) + encoded_weight = len(content.encode("utf-8")) + len(effective_name.encode("utf-8")) + assert encoded_weight > character_weight + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=2, + max_bytes=1024 * 1024, + max_input_bytes=character_weight, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + + capabilities.classify_skill_capability_evidence(content, effective_name) + capabilities.classify_skill_capability_evidence(content, effective_name) + + assert scan_calls == [(content, effective_name), (content, effective_name)] + assert cache.info().entry_count == 0 + assert cache.info().weight_bytes == 0 + + +def test_record_charge_and_multi_entry_byte_eviction(monkeypatch, scan_calls) -> None: + charged_content = _document( + "charged", + 'git commit -m "one"\ngh issue edit 1 --body-file two.md', + ) + charged_result = capabilities._scan_skill_capability_evidence_uncached( + charged_content, "charged" + ) + charged_key_weight = capabilities._skill_capability_evidence_input_weight_bytes( + charged_content, "charged" + ) + charged_weight = capabilities._skill_capability_evidence_entry_weight_bytes( + charged_key_weight, + charged_result, + ) + assert charged_weight == ( + charged_key_weight + + sum(capabilities._retained_string_weight_bytes(item.source) for item in charged_result) + + 2 * capabilities._SKILL_CAPABILITY_EVIDENCE_RECORD_WEIGHT_BYTES + ) + + small_a = _document("a", "a") + small_b = _document("b", "b") + large = _document("large", "x" * 100) + large_weight = capabilities._skill_capability_evidence_input_weight_bytes(large, "large") + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=8, + max_bytes=large_weight, + max_input_bytes=large_weight, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + capabilities.classify_skill_capability_evidence(small_a) + capabilities.classify_skill_capability_evidence(small_b) + large_result = capabilities.classify_skill_capability_evidence(large) + + assert cache.info().entry_count == 1 + assert cache.info().weight_bytes == large_weight + assert capabilities.classify_skill_capability_evidence(large) is large_result + capabilities.classify_skill_capability_evidence(small_a) + assert Counter(name for _, name in scan_calls)["a"] == 2 + assert cache.info().entry_count <= cache.info().max_entries + assert cache.info().weight_bytes <= cache.info().max_bytes + + +def test_character_preflight_bypasses_exact_accounting_and_retention( + monkeypatch, +) -> None: + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=2, + max_bytes=1024, + max_input_bytes=64, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + content = _document("oversized", 'git commit -m "oversized"\n' + "x" * 80) + calls = 0 + original_scanner = capabilities._scan_skill_capability_evidence_uncached + + def scanner(body: str, name: str): + nonlocal calls + calls += 1 + return original_scanner(body, name) + + monkeypatch.setattr(capabilities, "_scan_skill_capability_evidence_uncached", scanner) + monkeypatch.setattr( + capabilities, + "_skill_capability_evidence_input_weight_bytes", + lambda *_args: pytest.fail("exact accounting should be bypassed"), + ) + + first = capabilities.classify_skill_capability_evidence(content, "oversized") + second = capabilities.classify_skill_capability_evidence(content, "oversized") + + assert first == second + assert first[0].capability == "git_metadata_write" + assert calls == 2 + assert cache.info().entry_count == 0 + assert cache.info().weight_bytes == 0 + + +def test_completed_entry_overflow_scans_correctly_without_retention( + monkeypatch, scan_calls +) -> None: + content = _document("completed", 'git commit -m "completed"') + key_weight = capabilities._skill_capability_evidence_input_weight_bytes(content, "completed") + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=2, + max_bytes=key_weight, + max_input_bytes=key_weight, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + + first = capabilities.classify_skill_capability_evidence(content, "completed") + second = capabilities.classify_skill_capability_evidence(content, "completed") + + assert first == second + assert first[0].capability == "git_metadata_write" + assert len(scan_calls) == 2 + assert cache.info().entry_count == 0 + assert cache.info().weight_bytes == 0 + + +def test_overlapping_cold_callers_share_one_generation(evidence_cache, monkeypatch) -> None: + content = _document("concurrent", 'git commit -m "concurrent"') + entered = Event() + release = Event() + counter_lock = Lock() + scans = 0 + original = capabilities._scan_skill_capability_evidence_uncached + + def blocked_scanner(body: str, name: str): + nonlocal scans + with counter_lock: + scans += 1 + entered.set() + assert release.wait(2) + return original(body, name) + + monkeypatch.setattr(capabilities, "_scan_skill_capability_evidence_uncached", blocked_scanner) + futures: list[Future[tuple]] = [] + try: + with ThreadPoolExecutor(max_workers=4) as executor: + futures.append( + executor.submit( + capabilities.classify_skill_capability_evidence, + content, + "concurrent", + ) + ) + assert entered.wait(2) + futures.extend( + executor.submit( + capabilities.classify_skill_capability_evidence, + content, + "concurrent", + ) + for _ in range(3) + ) + _wait_for_cache_info( + evidence_cache, + lambda info: info.inflight_builds == 1 and info.inflight_waiters == 3, + ) + release.set() + results = [future.result(timeout=2) for future in futures] + finally: + release.set() + + assert scans == 1 + assert all(result is results[0] for result in results) + assert evidence_cache.info().inflight_builds == 0 + assert evidence_cache.info().inflight_waiters == 0 + + +def test_different_cold_keys_scan_concurrently_outside_global_lock( + evidence_cache, monkeypatch +) -> None: + both_entered = Event() + release = Event() + counter_lock = Lock() + active = 0 + max_active = 0 + original = capabilities._scan_skill_capability_evidence_uncached + + def blocked_scanner(body: str, name: str): + nonlocal active, max_active + with counter_lock: + active += 1 + max_active = max(max_active, active) + if active == 2: + both_entered.set() + try: + assert release.wait(2) + return original(body, name) + finally: + with counter_lock: + active -= 1 + + monkeypatch.setattr(capabilities, "_scan_skill_capability_evidence_uncached", blocked_scanner) + try: + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit( + capabilities.classify_skill_capability_evidence, + _document("first", "first"), + ) + second = executor.submit( + capabilities.classify_skill_capability_evidence, + _document("second", "second"), + ) + assert both_entered.wait(2) + release.set() + first.result(timeout=2) + second.result(timeout=2) + finally: + release.set() + + assert max_active == 2 + assert evidence_cache.info().entry_count == 2 + + +def test_synchronized_warm_callers_reuse_resident_identity(evidence_cache, scan_calls) -> None: + content = _document("warm", 'git commit -m "warm"') + resident = capabilities.classify_skill_capability_evidence(content) + + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + executor.submit( + capabilities.classify_skill_capability_evidence, + content, + ) + for _ in range(16) + ] + results = [future.result(timeout=2) for future in futures] + + assert all(result is resident for result in results) + assert len(scan_calls) == 1 + assert evidence_cache.info().inflight_builds == 0 + + +def test_waiter_receives_builder_tuple_after_resident_eviction( + monkeypatch, +) -> None: + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=1, + max_bytes=1024 * 1024, + max_input_bytes=64 * 1024, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + first_content = _document("first", 'git commit -m "first"') + second_content = _document("second", 'git commit -m "second"') + scanner_entered = Event() + scanner_release = Event() + waiter_holds_result = Event() + waiter_release = Event() + original_scanner = capabilities._scan_skill_capability_evidence_uncached + original_wait = capabilities._SkillCapabilityEvidenceCache._wait_for_build + + def blocked_scanner(body: str, name: str): + if name == "first": + scanner_entered.set() + assert scanner_release.wait(2) + return original_scanner(body, name) + + def paused_wait(self, key, state): + result = original_wait(self, key, state) + waiter_holds_result.set() + assert waiter_release.wait(2) + return result + + monkeypatch.setattr(capabilities, "_scan_skill_capability_evidence_uncached", blocked_scanner) + monkeypatch.setattr( + capabilities._SkillCapabilityEvidenceCache, + "_wait_for_build", + paused_wait, + ) + try: + with ThreadPoolExecutor(max_workers=2) as executor: + builder = executor.submit( + capabilities.classify_skill_capability_evidence, + first_content, + ) + assert scanner_entered.wait(2) + waiter = executor.submit( + capabilities.classify_skill_capability_evidence, + first_content, + ) + _wait_for_cache_info(cache, lambda info: info.inflight_waiters == 1) + scanner_release.set() + builder_result = builder.result(timeout=2) + assert waiter_holds_result.wait(2) + capabilities.classify_skill_capability_evidence(second_content) + waiter_release.set() + waiter_result = waiter.result(timeout=2) + finally: + scanner_release.set() + waiter_release.set() + + assert waiter_result is builder_result + assert cache.info().entry_count == 1 + + +def test_resuming_waiter_refreshes_same_resident_generation_to_mru( + monkeypatch, scan_calls +) -> None: + cache = capabilities._SkillCapabilityEvidenceCache( + max_entries=2, + max_bytes=1024 * 1024, + max_input_bytes=64 * 1024, + ) + monkeypatch.setattr(capabilities, "_SKILL_CAPABILITY_EVIDENCE_CACHE", cache) + first_content = _document("first", 'git commit -m "first"') + second_content = _document("second", 'git commit -m "second"') + third_content = _document("third", 'git commit -m "third"') + scanner_entered = Event() + scanner_release = Event() + waiter_awakened = Event() + waiter_release = Event() + original_scanner = capabilities._scan_skill_capability_evidence_uncached + + class PausingEvent: + def __init__(self) -> None: + self._event = Event() + + def set(self) -> None: + self._event.set() + + def wait(self, timeout: float | None = None) -> bool: + assert self._event.wait(timeout) + waiter_awakened.set() + assert waiter_release.wait(2) + return True + + def new_build_state(_self): + return capabilities._SkillCapabilityEvidenceBuildState( + event=PausingEvent(), # type: ignore[arg-type] + ) + + def blocked_scanner(body: str, name: str): + if name == "first": + scanner_entered.set() + assert scanner_release.wait(2) + return original_scanner(body, name) + + monkeypatch.setattr( + capabilities._SkillCapabilityEvidenceCache, + "_new_build_state", + new_build_state, + ) + monkeypatch.setattr( + capabilities, + "_scan_skill_capability_evidence_uncached", + blocked_scanner, + ) + try: + with ThreadPoolExecutor(max_workers=2) as executor: + builder = executor.submit( + capabilities.classify_skill_capability_evidence, + first_content, + ) + assert scanner_entered.wait(2) + waiter = executor.submit( + capabilities.classify_skill_capability_evidence, + first_content, + ) + _wait_for_cache_info(cache, lambda info: info.inflight_waiters == 1) + scanner_release.set() + first_result = builder.result(timeout=2) + assert waiter_awakened.wait(2) + capabilities.classify_skill_capability_evidence(second_content) + waiter_release.set() + assert waiter.result(timeout=2) is first_result + finally: + scanner_release.set() + waiter_release.set() + + capabilities.classify_skill_capability_evidence(third_content) + assert capabilities.classify_skill_capability_evidence(first_content) is first_result + capabilities.classify_skill_capability_evidence(second_content) + assert Counter(name for _, name in scan_calls) == Counter( + {"second": 2, "first": 1, "third": 1} + ) + + +def test_scanner_failure_releases_waiters_and_allows_retry(evidence_cache, monkeypatch) -> None: + content = _document("failure", 'git commit -m "failure"') + entered = Event() + release = Event() + failure = RuntimeError("scanner failed") + original = capabilities._scan_skill_capability_evidence_uncached + should_fail = True + + def scanner(body: str, name: str): + if should_fail: + entered.set() + assert release.wait(2) + raise failure + return original(body, name) + + monkeypatch.setattr(capabilities, "_scan_skill_capability_evidence_uncached", scanner) + try: + with ThreadPoolExecutor(max_workers=3) as executor: + futures = [ + executor.submit( + capabilities.classify_skill_capability_evidence, + content, + ) + for _ in range(3) + ] + assert entered.wait(2) + _wait_for_cache_info(evidence_cache, lambda info: info.inflight_waiters == 2) + release.set() + raised_errors: list[RuntimeError] = [] + for future in futures: + with pytest.raises(RuntimeError) as raised: + future.result(timeout=2) + raised_errors.append(raised.value) + finally: + release.set() + + assert sum(error is failure for error in raised_errors) == 1 + waiter_errors = [error for error in raised_errors if error is not failure] + assert len(waiter_errors) == 2 + assert waiter_errors[0] is not waiter_errors[1] + assert all(error.__cause__ is failure for error in waiter_errors) + assert evidence_cache.info().inflight_builds == 0 + assert evidence_cache.info().inflight_waiters == 0 + should_fail = False + retry = capabilities.classify_skill_capability_evidence(content) + assert retry[0].capability == "git_metadata_write" + + +def test_partial_bookkeeping_failure_resets_resident_state_and_allows_retry( + evidence_cache, monkeypatch +) -> None: + content = _document("bookkeeping", 'git commit -m "bookkeeping"') + scanner_entered = Event() + scanner_release = Event() + original_eviction = capabilities._SkillCapabilityEvidenceCache._evict_if_needed_locked + original_scanner = capabilities._scan_skill_capability_evidence_uncached + failure = RuntimeError("bookkeeping failed") + + def fail_after_insertion(self): + raise failure + + def blocked_scanner(body: str, name: str): + scanner_entered.set() + assert scanner_release.wait(2) + return original_scanner(body, name) + + monkeypatch.setattr( + capabilities._SkillCapabilityEvidenceCache, + "_evict_if_needed_locked", + fail_after_insertion, + ) + monkeypatch.setattr( + capabilities, + "_scan_skill_capability_evidence_uncached", + blocked_scanner, + ) + with ThreadPoolExecutor(max_workers=2) as executor: + builder = executor.submit( + capabilities.classify_skill_capability_evidence, + content, + ) + try: + assert scanner_entered.wait(2) + waiter = executor.submit( + capabilities.classify_skill_capability_evidence, + content, + ) + _wait_for_cache_info(evidence_cache, lambda info: info.inflight_waiters == 1) + scanner_release.set() + with pytest.raises(RuntimeError) as builder_raised: + builder.result(timeout=2) + with pytest.raises(RuntimeError) as waiter_raised: + waiter.result(timeout=2) + finally: + scanner_release.set() + + assert builder_raised.value is failure + assert waiter_raised.value is not failure + assert waiter_raised.value.__cause__ is failure + assert evidence_cache.info().entry_count == 0 + assert evidence_cache.info().weight_bytes == 0 + assert evidence_cache.info().inflight_builds == 0 + assert evidence_cache.info().inflight_waiters == 0 + + monkeypatch.setattr( + capabilities._SkillCapabilityEvidenceCache, + "_evict_if_needed_locked", + original_eviction, + ) + monkeypatch.setattr( + capabilities, + "_scan_skill_capability_evidence_uncached", + original_scanner, + ) + retry = capabilities.classify_skill_capability_evidence(content) + assert retry[0].capability == "git_metadata_write"