From 57dfe585d81570769fa5e5e0a84b31ae4a38d3a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 04:24:18 +0000 Subject: [PATCH] fix(windows): close skill-markdown graph clients so tests can unlink sqlite Default auto transport opens better-sqlite3. Import/export left the handle open, so afterEach rmSync hit EBUSY on Windows validate. Close the client in finally and ignore leftover unlink errors. --- CHANGELOG.md | 6 + src/surfaces/cli/runtime/knowledge.ts | 293 ++++++++++++----------- tests/skill-markdown-progressive.test.ts | 7 +- 3 files changed, 169 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca0bff47..9381dbf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented in this file. +## [Unreleased] + +### Fixed + +- **Windows skill markdown 测试 EBUSY**:`importSkillsFromMarkdownRuntime` / `exportSkillsToMarkdownRuntime` 打开默认 `auto`→sqlite 图库后不 `close()`,`afterEach` 删临时目录时 `graphflow-graph.sqlite` 仍被占用。现于 try/finally 关闭 client。 + ## [1.24.0] - 2026-09-19 ### Added — R7 三件套首发:语义召回默认开 + Agent Skills 标准分发 + 可核验隐私 diff --git a/src/surfaces/cli/runtime/knowledge.ts b/src/surfaces/cli/runtime/knowledge.ts index c47e9b6a..fbccd3b6 100644 --- a/src/surfaces/cli/runtime/knowledge.ts +++ b/src/surfaces/cli/runtime/knowledge.ts @@ -19,10 +19,7 @@ import { import { parseSkillState, serializeAtomic } from "../../../learning/skill-store"; import { dialogueSessionIdFor, listDialogueTurns } from "../../../learning/dialogue-thread"; -function resolveRuntimeConfig( - configPath?: string, - rootDir?: string -) { +function resolveRuntimeConfig(configPath?: string, rootDir?: string) { const resolved = resolveConfig(configPath, rootDir ? { rootDir } : undefined); return bindRuntimeWorkspaceRoot( resolved, @@ -34,6 +31,14 @@ function resolveRuntimeConfig( ); } +function closeGraphClient(client: GraphClient): void { + try { + void client.close?.(); + } catch { + // Windows keeps the sqlite file locked if close throws after a failed open. + } +} + export interface SkillMarkdownExportResult { outputDir: string; fileCount: number; @@ -51,57 +56,61 @@ export async function exportSkillsToMarkdownRuntime( ): Promise { const config = resolveRuntimeConfig(configPath, options?.rootDir); const client = createGraphClient(config); - const snapshot = client.readSnapshot?.() ?? { nodes: [], edges: [] }; - const workspaceRoot = config.graphPolicy.workspaceRoot ?? process.cwd(); - const outputDir = options?.outputDir - ? (isAbsolute(options.outputDir) - ? options.outputDir - : join(workspaceRoot, options.outputDir)) - : join(workspaceRoot, ".graphflow", "skills", "markdown"); - mkdirSync(outputDir, { recursive: true }); + try { + const snapshot = client.readSnapshot?.() ?? { nodes: [], edges: [] }; + const workspaceRoot = config.graphPolicy.workspaceRoot ?? process.cwd(); + const outputDir = options?.outputDir + ? isAbsolute(options.outputDir) + ? options.outputDir + : join(workspaceRoot, options.outputDir) + : join(workspaceRoot, ".graphflow", "skills", "markdown"); + mkdirSync(outputDir, { recursive: true }); - let bytes = 0; - let fileCount = 0; - let skippedComposites = 0; - let referenceFileCount = 0; - const invalid: Array<{ file: string; violations: string[] }> = []; - const usedDirs = new Set(); - for (const node of snapshot.nodes) { - if (node.type !== "Skill") continue; - const state = parseSkillState(node.content); - if (!state) { - if (node.content.includes('"kind":"composite"')) skippedComposites += 1; - continue; - } - // agentskills.io layout: one directory per skill, SKILL.md inside, and - // the directory name MUST equal the spec name. Oversized guidance moves - // to references/ (progressive disclosure) so the body stays a pointer. - let dirName = skillDirectoryFor(state); - const base = toSpecName(state.name); - let suffix = 2; - while (usedDirs.has(dirName.toLowerCase())) { - dirName = `${base}-${suffix}`; - suffix += 1; - } - usedDirs.add(dirName.toLowerCase()); - const bundle = skillToSkillMarkdownBundle(state); - const violations = validateSkillMarkdown(bundle.markdown); - const relPath = `${dirName}/SKILL.md`; - if (violations.length > 0) invalid.push({ file: relPath, violations }); - const skillDir = join(outputDir, dirName); - mkdirSync(skillDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), bundle.markdown, "utf8"); - bytes += Buffer.byteLength(bundle.markdown); - fileCount += 1; - for (const reference of bundle.references) { - const refPath = join(skillDir, ...reference.path.split("/")); - mkdirSync(dirname(refPath), { recursive: true }); - writeFileSync(refPath, reference.content, "utf8"); - bytes += Buffer.byteLength(reference.content); - referenceFileCount += 1; + let bytes = 0; + let fileCount = 0; + let skippedComposites = 0; + let referenceFileCount = 0; + const invalid: Array<{ file: string; violations: string[] }> = []; + const usedDirs = new Set(); + for (const node of snapshot.nodes) { + if (node.type !== "Skill") continue; + const state = parseSkillState(node.content); + if (!state) { + if (node.content.includes('"kind":"composite"')) skippedComposites += 1; + continue; + } + // agentskills.io layout: one directory per skill, SKILL.md inside, and + // the directory name MUST equal the spec name. Oversized guidance moves + // to references/ (progressive disclosure) so the body stays a pointer. + let dirName = skillDirectoryFor(state); + const base = toSpecName(state.name); + let suffix = 2; + while (usedDirs.has(dirName.toLowerCase())) { + dirName = `${base}-${suffix}`; + suffix += 1; + } + usedDirs.add(dirName.toLowerCase()); + const bundle = skillToSkillMarkdownBundle(state); + const violations = validateSkillMarkdown(bundle.markdown); + const relPath = `${dirName}/SKILL.md`; + if (violations.length > 0) invalid.push({ file: relPath, violations }); + const skillDir = join(outputDir, dirName); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), bundle.markdown, "utf8"); + bytes += Buffer.byteLength(bundle.markdown); + fileCount += 1; + for (const reference of bundle.references) { + const refPath = join(skillDir, ...reference.path.split("/")); + mkdirSync(dirname(refPath), { recursive: true }); + writeFileSync(refPath, reference.content, "utf8"); + bytes += Buffer.byteLength(reference.content); + referenceFileCount += 1; + } } + return { outputDir, fileCount, bytes, skippedComposites, referenceFileCount, invalid }; + } finally { + closeGraphClient(client); } - return { outputDir, fileCount, bytes, skippedComposites, referenceFileCount, invalid }; } export interface SkillMarkdownImportResult { @@ -149,7 +158,10 @@ function isSpecLayoutFile(file: string): boolean { return basename(file).toLowerCase() === "skill.md"; } -async function existingSkillUpdatedAt(client: GraphClient, id: string): Promise { +async function existingSkillUpdatedAt( + client: GraphClient, + id: string +): Promise { const hits = await (client.getNodesByIds?.([id]) ?? client.queryByKeyword(id)); const node = hits.find((item) => item.id === id && item.type === "Skill"); if (!node) return undefined; @@ -171,58 +183,62 @@ export async function importSkillsFromMarkdownRuntime( const files = collectMarkdownFiles(inputPath); const client = createGraphClient(config); - let imported = 0; - let updated = 0; - let skipped = 0; - const invalid: Array<{ file: string; violations: string[] }> = []; - for (const file of files) { - const raw = readFileSync(file, "utf8"); - // Spec gate: reject files that violate agentskills.io shape before parsing. - // Missing description is advisory (importable); bad name/shape is rejected. - const violations = validateSkillMarkdown(raw).filter( - (v) => !v.startsWith("description is required") - ); - if (violations.length > 0) { - invalid.push({ file, violations }); - skipped += 1; - continue; - } - const state = parseSkillMarkdown(raw); - if (!state) { - skipped += 1; - continue; - } - // agentskills.io: the parent directory name must equal the skill name. - // Only enforced for spec-layout files; flat legacy exports (name.md at - // the scan root) stay importable. - if (isSpecLayoutFile(file)) { - const parentDir = basename(dirname(file)); - const specName = toSpecName(state.name); - if (parentDir.toLowerCase() !== specName.toLowerCase()) { - invalid.push({ - file, - violations: [ - `directory name "${parentDir}" must equal the skill name "${specName}" (agentskills.io)`, - ], - }); + try { + let imported = 0; + let updated = 0; + let skipped = 0; + const invalid: Array<{ file: string; violations: string[] }> = []; + for (const file of files) { + const raw = readFileSync(file, "utf8"); + // Spec gate: reject files that violate agentskills.io shape before parsing. + // Missing description is advisory (importable); bad name/shape is rejected. + const violations = validateSkillMarkdown(raw).filter( + (v) => !v.startsWith("description is required") + ); + if (violations.length > 0) { + invalid.push({ file, violations }); skipped += 1; continue; } + const state = parseSkillMarkdown(raw); + if (!state) { + skipped += 1; + continue; + } + // agentskills.io: the parent directory name must equal the skill name. + // Only enforced for spec-layout files; flat legacy exports (name.md at + // the scan root) stay importable. + if (isSpecLayoutFile(file)) { + const parentDir = basename(dirname(file)); + const specName = toSpecName(state.name); + if (parentDir.toLowerCase() !== specName.toLowerCase()) { + invalid.push({ + file, + violations: [ + `directory name "${parentDir}" must equal the skill name "${specName}" (agentskills.io)`, + ], + }); + skipped += 1; + continue; + } + } + const previousUpdatedAt = await existingSkillUpdatedAt(client, state.id); + if ( + previousUpdatedAt !== undefined && + !options?.force && + previousUpdatedAt >= state.updatedAt + ) { + skipped += 1; + continue; + } + await client.upsertNodes([{ id: state.id, type: "Skill", content: serializeAtomic(state) }]); + if (previousUpdatedAt === undefined) imported += 1; + else updated += 1; } - const previousUpdatedAt = await existingSkillUpdatedAt(client, state.id); - if ( - previousUpdatedAt !== undefined && - !options?.force && - previousUpdatedAt >= state.updatedAt - ) { - skipped += 1; - continue; - } - await client.upsertNodes([{ id: state.id, type: "Skill", content: serializeAtomic(state) }]); - if (previousUpdatedAt === undefined) imported += 1; - else updated += 1; + return { inputPath, imported, updated, skipped, total: files.length, invalid }; + } finally { + closeGraphClient(client); } - return { inputPath, imported, updated, skipped, total: files.length, invalid }; } export interface DialogueKnowledgeExtractionResult { @@ -245,45 +261,50 @@ export async function extractDialogueKnowledgeRuntime( ): Promise { const config = resolveRuntimeConfig(configPath, options?.rootDir); const workspaceRoot = config.graphPolicy.workspaceRoot ?? process.cwd(); - const sessionId = options?.all || !options?.sessionId - ? undefined - : options.sessionId.startsWith("dialogue-session:") - ? options.sessionId - : dialogueSessionIdFor(options.sessionId, workspaceRoot); + const sessionId = + options?.all || !options?.sessionId + ? undefined + : options.sessionId.startsWith("dialogue-session:") + ? options.sessionId + : dialogueSessionIdFor(options.sessionId, workspaceRoot); const client = createGraphClient(config); - const turns = await listDialogueTurns(client, { - ...(sessionId ? { sessionId } : {}), - ...(options?.limit !== undefined ? { limit: options.limit } : {}), - }); - const records: KnowledgeTurnRecord[] = turns.map((turn) => ({ - turnId: turn.id, - query: turn.userQuery, - reply: turn.assistantReply, - })); - const fragment = extractEngineeringKnowledgeGraphFragment({ turns: records }); + try { + const turns = await listDialogueTurns(client, { + ...(sessionId ? { sessionId } : {}), + ...(options?.limit !== undefined ? { limit: options.limit } : {}), + }); + const records: KnowledgeTurnRecord[] = turns.map((turn) => ({ + turnId: turn.id, + query: turn.userQuery, + reply: turn.assistantReply, + })); + const fragment = extractEngineeringKnowledgeGraphFragment({ turns: records }); - // The extractor records source turn IDs in metadata. Emit one provenance - // edge per actual dialogue-turn node so Concept/Requirement remain auditable. - const edges: GraphEdge[] = []; - for (const node of fragment.nodes) { - const metadata = node.metadata as { - sourceTurnIds?: string[]; - }; - for (const sourceId of metadata.sourceTurnIds ?? []) { - edges.push({ from: node.id, to: sourceId, relation: "derived_from" }); + // The extractor records source turn IDs in metadata. Emit one provenance + // edge per actual dialogue-turn node so Concept/Requirement remain auditable. + const edges: GraphEdge[] = []; + for (const node of fragment.nodes) { + const metadata = node.metadata as { + sourceTurnIds?: string[]; + }; + for (const sourceId of metadata.sourceTurnIds ?? []) { + edges.push({ from: node.id, to: sourceId, relation: "derived_from" }); + } } - } - const apply = options?.apply ?? true; - if (apply) { - if (fragment.nodes.length > 0) await client.upsertNodes(fragment.nodes); - if (edges.length > 0) await client.upsertEdges(edges); + const apply = options?.apply ?? true; + if (apply) { + if (fragment.nodes.length > 0) await client.upsertNodes(fragment.nodes); + if (edges.length > 0) await client.upsertEdges(edges); + } + return { + scannedTurns: turns.length, + requirements: fragment.nodes.filter((node) => node.type === "Requirement").length, + concepts: fragment.nodes.filter((node) => node.type === "Concept").length, + edges: edges.length, + applied: apply, + }; + } finally { + closeGraphClient(client); } - return { - scannedTurns: turns.length, - requirements: fragment.nodes.filter((node) => node.type === "Requirement").length, - concepts: fragment.nodes.filter((node) => node.type === "Concept").length, - edges: edges.length, - applied: apply, - }; } diff --git a/tests/skill-markdown-progressive.test.ts b/tests/skill-markdown-progressive.test.ts index ade67af2..6c1cd417 100644 --- a/tests/skill-markdown-progressive.test.ts +++ b/tests/skill-markdown-progressive.test.ts @@ -36,7 +36,12 @@ const dirs: string[] = []; afterEach(() => { while (dirs.length > 0) { const dir = dirs.pop(); - if (dir) rmSync(dir, { recursive: true, force: true }); + if (!dir) continue; + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Windows: leftover sqlite handles used to EBUSY here; runtimes now close. + } } });