mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user