fix: preserve skill snapshot freshness

This commit is contained in:
Shakker
2026-05-28 19:46:00 +01:00
committed by Shakker
parent 98834defb0
commit 407ffdef0b
2 changed files with 102 additions and 6 deletions

View File

@@ -96,6 +96,78 @@ describe("SkillsService", () => {
expect(after.entries.map((entry) => entry.name)).toContain("service-zero-beta");
});
it("does not cache explicit indexes when skill watching is disabled", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-watch-alpha", description: "Alpha workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const config = { skills: { load: { watch: false } } } satisfies OpenClawConfig;
const before = service.getIndex({ workspaceDir, ...roots, config, snapshotVersion: 1 });
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-watch-beta", description: "Beta workflow" },
]);
const after = service.getIndex({ workspaceDir, ...roots, config, snapshotVersion: 1 });
expect(after).not.toBe(before);
expect(before.entries.map((entry) => entry.name)).not.toContain("service-watch-beta");
expect(after.entries.map((entry) => entry.name)).toContain("service-watch-beta");
});
it("keeps snapshots uncached even when callers pass a positive version", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-snapshot-alpha", description: "Alpha workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const before = service.buildSnapshot(workspaceDir, { ...roots, snapshotVersion: 1 });
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-snapshot-beta", description: "Beta workflow" },
]);
const after = service.buildSnapshot(workspaceDir, { ...roots, snapshotVersion: 1 });
expect(before.skills.map((skill) => skill.name)).not.toContain("service-snapshot-beta");
expect(after.skills.map((skill) => skill.name)).toContain("service-snapshot-beta");
});
it("bounds cached indexes across workspace scopes", async () => {
const service = new SkillsService();
const workspaces = await Promise.all(
Array.from({ length: 17 }, async (_, index) => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: `service-lru-${index}`, description: "Cached workflow" },
]);
return workspaceDir;
}),
);
const firstWorkspace = workspaces[0]!;
const first = service.getIndex({
workspaceDir: firstWorkspace,
...isolatedSkillRoots(firstWorkspace),
snapshotVersion: 1,
});
for (const workspaceDir of workspaces.slice(1)) {
service.getIndex({
workspaceDir,
...isolatedSkillRoots(workspaceDir),
snapshotVersion: 1,
});
}
const firstAgain = service.getIndex({
workspaceDir: firstWorkspace,
...isolatedSkillRoots(firstWorkspace),
snapshotVersion: 1,
});
expect(firstAgain).not.toBe(first);
});
it("includes plugin config in the versioned cache key", async () => {
const workspaceDir = await makeTempWorkspace();
const roots = isolatedSkillRoots(workspaceDir);

View File

@@ -11,6 +11,8 @@ import {
loadWorkspaceSkillEntries,
} from "./workspace.js";
const MAX_SKILL_INDEX_CACHE_ENTRIES = 16;
export type SkillIndexRequest = {
workspaceDir: string;
config?: OpenClawConfig;
@@ -38,19 +40,22 @@ export class SkillsService {
getIndex(request: SkillIndexRequest): SkillIndex {
const snapshotVersion =
request.snapshotVersion ?? getSkillsSnapshotVersion(request.workspaceDir);
if (!shouldCacheSkillIndex(snapshotVersion)) {
if (!shouldCacheSkillIndex(request, snapshotVersion)) {
return this.loadIndex(request, buildUncachedSkillIndexCacheKey(request, snapshotVersion));
}
const cacheKeyParts = buildSkillIndexCacheKeyParts(request, snapshotVersion);
const cacheKey = stringifyCacheKeyParts(cacheKeyParts);
const cached = this.cache.get(cacheKey);
if (cached) {
this.cache.delete(cacheKey);
this.cache.set(cacheKey, cached);
return cached;
}
const index = this.loadIndex(request, cacheKey);
this.pruneScope(cacheKeyParts.scope, cacheKey);
this.cache.set(cacheKey, index);
this.cacheScopes.set(cacheKey, cacheKeyParts.scope);
this.pruneCapacity();
return index;
}
@@ -74,9 +79,10 @@ export class SkillsService {
snapshotVersion: opts?.snapshotVersion,
};
const snapshotVersion = request.snapshotVersion ?? getSkillsSnapshotVersion(workspaceDir);
const index = shouldCacheSkillIndex(snapshotVersion)
? this.getIndex(request)
: this.loadIndex(request, buildUncachedSkillIndexCacheKey(request, snapshotVersion));
const index = this.loadIndex(
request,
buildUncachedSkillIndexCacheKey(request, snapshotVersion),
);
return buildSkillSnapshotFromIndex(workspaceDir, index, opts);
}
@@ -94,6 +100,17 @@ export class SkillsService {
this.cacheScopes.delete(key);
}
}
private pruneCapacity(): void {
while (this.cache.size > MAX_SKILL_INDEX_CACHE_ENTRIES) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey === undefined) {
break;
}
this.cache.delete(oldestKey);
this.cacheScopes.delete(oldestKey);
}
}
}
export const skillsService = new SkillsService();
@@ -138,8 +155,15 @@ type SkillIndexCacheKeyParts = {
snapshotVersion: number;
};
function shouldCacheSkillIndex(snapshotVersion: number | undefined): boolean {
return typeof snapshotVersion === "number" && snapshotVersion > 0;
function shouldCacheSkillIndex(
request: SkillIndexRequest,
snapshotVersion: number | undefined,
): boolean {
return (
typeof snapshotVersion === "number" &&
snapshotVersion > 0 &&
request.config?.skills?.load?.watch !== false
);
}
function buildUncachedSkillIndexCacheKey(