Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/core/src/config/plugin/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,35 @@ export const Plugin = define({

const refresh = Effect.fn("ConfigCompatibilityPlugin.refresh")(
function* () {
yield* FiberMap.clear(watches)
const roots = config.compatibility ? yield* config.compatibility() : { claude: [], agents: [] }
const directories = [...roots.claude, ...roots.agents].map((root) => path.join(root, "skills"))
const loaded = new Map<Skill.ID, Skill.Info>()
const plan = new Map<string, string>()
for (const directory of directories) {
const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined))
if (!resolved) continue
yield* watch(resolved, "directory")
plan.set(`directory:${resolved}`, resolved)
for (const filepath of yield* SkillScan.scan(resolved)) {
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined))
if (!content) continue
const parsed = SkillFile.parse(resolved, filepath, content)
if (parsed._tag === "Parsed") loaded.set(parsed.skill.id, parsed.skill)
}
}
// Reconcile rather than clear. A blanket clear defeats the `onlyIfMissing`
// on `watch`, so every event reaching the debounce below tore down and
// rebuilt each watch, and each rebuild published an update that fed the
// same debounce again — a directory nothing was writing to kept the
// service busy at ~37% CPU. Keeping the watches that are still planned
// leaves that loop without an event to sustain it.
const watching = new Set(Array.from(watches, ([key]) => key))
for (const key of watching) {
if (!plan.has(key)) yield* FiberMap.remove(watches, key)
}
for (const [key, target] of plan) {
if (watching.has(key)) continue
yield* watch(target, "directory")
}
skills.splice(0, skills.length, ...loaded.values())
},
(effect) => lock.withPermit(effect),
Expand Down
32 changes: 32 additions & 0 deletions packages/core/test/config/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,4 +426,36 @@ describe("ConfigSkillPlugin.Plugin", () => {
),
),
)

it.live("keeps a compatibility watch instead of rebuilding it on every event", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const claude = path.join(tmp.path, "claude")
const agents = path.join(tmp.path, "agents")
const home = path.join(tmp.path, "home")
const directory = path.join(tmp.path, "project")
const target = path.join(agents, "skills")
yield* Effect.promise(() =>
Promise.all([claude, home, directory, target].map((item) => fs.mkdir(item, { recursive: true }))),
)

yield* startEntries([], directory, home, emptyDiscovery, {
claude: [AbsolutePath.make(claude)],
agents: [AbsolutePath.make(agents)],
})
const watcher = yield* Watcher.Test
const watches = () =>
Effect.map(watcher.subscriptions(), (items) => items.filter((item) => item.path === target))
expect(yield* watches()).toHaveLength(1)

// Any event resettles the watch, and a rebuilt watch publishes again, so
// re-subscribing here is what turns one unrelated write into a loop.
yield* emitAndWait({ type: "update", path: path.join(target, "SKILL.md") })

expect(yield* watches()).toHaveLength(1)
}),
),
),
)
})
Loading