fix(skills): run orphan GC on empty merged skills to clear stale cache - #2665
fix(skills): run orphan GC on empty merged skills to clear stale cache#2665weixsun wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
85a7d19 to
e3f69a0
Compare
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR fixes a real and impactful bug: when all skills are removed from repositories or filtered out by visibility/skill filters, stale content in .skills-cache/ was never cleaned up, causing outdated skill resources to leak into the sandbox. The fix introduces a MergeResult record that carries both the merged skills map and a loadFailed flag, enabling a new stageEmptyRetainedIfSafe() helper that triggers orphan GC with an empty retained set — but only when the empty result is intentional (not caused by a transient repository failure). The outage-protection design is thoughtful, the code reuse of stager.stage(List.of(), sourceNamespaces) is clean (no API surface expansion needed), and the 5-test suite covers the key scenarios well including the critical load-failure preservation case.
| private Map<String, RepoBound> skillsForCall(RuntimeContext ctx) { | ||
| return frozenSkills != null ? frozenSkills : mergeRepositories(ctx); | ||
| private MergeResult skillsForCall(RuntimeContext ctx) { | ||
| return frozenSkills != null ? new MergeResult(frozenSkills, false) : mergeRepositories(ctx); |
There was a problem hiding this comment.
[nit] When frozenSkills != null, loadFailed is hardcoded to false. This means if all repositories failed during construction (with freezeRepositories=true), the loadFailed signal is silently discarded and a subsequent empty-merge will trigger cache GC. In practice this is harmless (empty frozen skills + empty cache = no-op GC), but a brief inline comment explaining why frozen mode always reports loadFailed=false would help future maintainers understand the intent.
| } | ||
|
|
||
| @Test | ||
| void onSystemPromptClearsSkillsCacheWhenVisibilityFilterHidesAll() throws IOException { |
There was a problem hiding this comment.
[nit] There's a test for onSystemPrompt + visibility-filter-hides-all (this test), but no symmetric test for prestageMarketplaceSkills + visibility-filter-hides-all. The skill-filter variant is covered (prestageMarketplaceSkillsClearsCacheWhenSkillFilterHidesAll), but visibility filter takes a different code path (applyVisibility returns empty before applySkillFilter is even reached). Consider adding a prestageMarketplaceSkillsClearsCacheWhenVisibilityFilterHidesAll test for symmetry.
| * is removed when no skill survives merging/filtering. Skipped when a repository failed to | ||
| * load — an empty result caused by an error must not wipe the cache (outage protection). | ||
| */ | ||
| private void stageEmptyRetainedIfSafe(boolean loadFailed) { |
There was a problem hiding this comment.
[praise] Clean design: reusing stager.stage(List.of(), sourceNamespaces) to trigger orphan GC through the existing public API avoids expanding MarketplaceStager's API surface (no need to widen garbageCollectOrphans visibility). The loadFailed guard is a well-thought-out outage protection — an empty result caused by a transient error must never wipe the cache.
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR fixes a real and impactful bug: when all skills are removed from repositories or filtered out by visibility/skill filters, stale content in .skills-cache/ was never cleaned up, causing outdated skill resources to leak into the sandbox. The fix introduces a MergeResult record that carries both the merged skills map and a loadFailed flag, enabling a new stageEmptyRetainedIfSafe() helper that triggers orphan GC with an empty retained set — but only when the empty result is intentional (not caused by a transient repository failure). The outage-protection design is thoughtful, the code reuse of stager.stage(List.of(), sourceNamespaces) is clean (no API surface expansion needed), and the 5-test suite covers the key scenarios well including the critical load-failure preservation case.
| private Map<String, RepoBound> skillsForCall(RuntimeContext ctx) { | ||
| return frozenSkills != null ? frozenSkills : mergeRepositories(ctx); | ||
| private MergeResult skillsForCall(RuntimeContext ctx) { | ||
| return frozenSkills != null ? new MergeResult(frozenSkills, false) : mergeRepositories(ctx); |
There was a problem hiding this comment.
[nit] When frozenSkills != null, loadFailed is hardcoded to false. This means if all repositories failed during construction (with freezeRepositories=true), the loadFailed signal is silently discarded and a subsequent empty-merge will trigger cache GC. In practice this is harmless (empty frozen skills + empty cache = no-op GC), but a brief inline comment explaining why frozen mode always reports loadFailed=false would help future maintainers understand the intent.
| } | ||
|
|
||
| @Test | ||
| void onSystemPromptClearsSkillsCacheWhenVisibilityFilterHidesAll() throws IOException { |
There was a problem hiding this comment.
[nit] There's a test for onSystemPrompt + visibility-filter-hides-all (this test), but no symmetric test for prestageMarketplaceSkills + visibility-filter-hides-all. The skill-filter variant is covered (prestageMarketplaceSkillsClearsCacheWhenSkillFilterHidesAll), but visibility filter takes a different code path (applyVisibility returns empty before applySkillFilter is even reached). Consider adding a prestageMarketplaceSkillsClearsCacheWhenVisibilityFilterHidesAll test for symmetry.
| * is removed when no skill survives merging/filtering. Skipped when a repository failed to | ||
| * load — an empty result caused by an error must not wipe the cache (outage protection). | ||
| */ | ||
| private void stageEmptyRetainedIfSafe(boolean loadFailed) { |
There was a problem hiding this comment.
[praise] Clean design: reusing stager.stage(List.of(), sourceNamespaces) to trigger orphan GC through the existing public API avoids expanding MarketplaceStager's API surface (no need to widen garbageCollectOrphans visibility). The loadFailed guard is a well-thought-out outage protection — an empty result caused by a transient error must never wipe the cache.
- Change mergeRepositories() to return both merged skills and a load failure flag - Use new MergeResult wrapper for skills and load failure tracking - Trigger orphan GC with empty retained white-list when merging yields no skills - Skip orphan GC if repository load failure occurred to protect cache during outages - Call stageEmptyRetainedIfSafe() in all code paths where merged skills are empty or filtered empty - Add tests verifying orphan GC clears .skills-cache on empty skills and preserves cache on load failure - Ensure safe behavior when MarketplaceStager is null during empty cache staging
Description
Closing #2664
Checklist
Please check the following items before code is ready to be reviewed.
mvn spotless:applymvn test)