fix(skills): clean up orphaned usage rows (#107144)

Remove lifecycle and usage rows atomically when curated skill files disappear.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
SunnyShu
2026-07-15 00:03:32 +08:00
committed by GitHub
parent b146ef8a59
commit 6de8aa6f9b
2 changed files with 39 additions and 16 deletions
+28 -4
View File
@@ -8,7 +8,12 @@ import {
setDiagnosticsEnabledForProcess,
waitForDiagnosticEventsDrained,
} from "../../infra/diagnostic-events.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import type { DB as OpenClawStateDatabase } from "../../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import { loadSkills } from "../loading/session.js";
import {
buildWorkspaceSkillSnapshot,
@@ -120,6 +125,15 @@ async function runSkillCuratorSweep(options: {
}
}
function readSkillUsageFiles(): string[] {
const database = openOpenClawStateDatabase({ env: process.env });
const kysely = getNodeSqliteKysely<Pick<OpenClawStateDatabase, "skill_usage">>(database.db);
return executeSqliteQuerySync(
database.db,
kysely.selectFrom("skill_usage").select("skill_file").orderBy("skill_file", "asc"),
).rows.map((row) => row.skill_file);
}
function addAppliedSkill(params: {
name: string;
appliedAtMs: number;
@@ -470,16 +484,26 @@ describe("skill curator lifecycle", () => {
]);
});
it("prunes lifecycle rows when curated skill files disappear", async () => {
it("prunes lifecycle and usage rows when curated skill files disappear", async () => {
const nowMs = Date.UTC(2026, 0, 1);
const skillFile = path.join(rootDir, "agent", "skills", "removed-skill", "SKILL.md");
addAppliedSkill({ name: "Removed Skill", appliedAtMs: nowMs });
await recordSkillUsage({
skillFile,
skillName: "Removed Skill",
skillSource: "workspace",
agentId: "main",
ts: nowMs,
});
await runSkillCuratorSweep({ env: process.env, nowMs });
expect(getSkillCuratorStatus({ env: process.env }).skills).toHaveLength(1);
expect(getSkillCuratorStatus({ env: process.env }).skills).toMatchObject([{ useCount: 1 }]);
expect(readSkillUsageFiles()).toEqual([skillFile]);
fs.rmSync(path.join(rootDir, "agent", "skills", "removed-skill", "SKILL.md"));
fs.rmSync(skillFile);
await runSkillCuratorSweep({ env: process.env, nowMs: nowMs + 1 });
expect(getSkillCuratorStatus({ env: process.env }).skills).toEqual([]);
expect(readSkillUsageFiles()).toEqual([]);
});
it("leaves manually authored skills outside lifecycle state", async () => {
+11 -12
View File
@@ -368,14 +368,10 @@ async function runSkillCuratorSweep(
const existingCurated: CuratedSkill[] = [];
const result = runOpenClawStateWriteTransaction(({ db }) => {
const kysely = getNodeSqliteKysely<CuratorDatabase>(db);
const lifecycleRows = executeSqliteQuerySync(
db,
kysely.selectFrom("skill_lifecycle").selectAll(),
).rows;
const usageRows = executeSqliteQuerySync(
db,
kysely.selectFrom("skill_usage").select(["skill_file", "last_used_at_ms"]),
).rows;
const lifecycleQuery = kysely.selectFrom("skill_lifecycle").selectAll();
const lifecycleRows = executeSqliteQuerySync(db, lifecycleQuery).rows;
const usageQuery = kysely.selectFrom("skill_usage").select(["skill_file", "last_used_at_ms"]);
const usageRows = executeSqliteQuerySync(db, usageQuery).rows;
const lifecycleByFile = new Map(lifecycleRows.map((row) => [row.skill_file, row]));
const usageByFile = new Map(usageRows.map((row) => [row.skill_file, row.last_used_at_ms]));
let stale = 0;
@@ -385,10 +381,13 @@ async function runSkillCuratorSweep(
for (const skill of curated) {
const existing = lifecycleByFile.get(skill.skillFile);
if (!fs.existsSync(skill.skillFile)) {
executeSqliteQuerySync(
db,
kysely.deleteFrom("skill_lifecycle").where("skill_file", "=", skill.skillFile),
);
// Lifecycle and usage share file identity; remove both atomically to avoid orphan rows.
for (const table of ["skill_lifecycle", "skill_usage"] as const) {
executeSqliteQuerySync(
db,
kysely.deleteFrom(table).where("skill_file", "=", skill.skillFile),
);
}
continue;
}
existingCurated.push(skill);