diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index ba65c7d05d..c89c716b13 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: 8a53195dd8c4880c5cfa758ccf666ae88b2e1030 -2026-07-27-skill-catalog-hot-refresh.zh.md: 8cf55534460d2926be706353afd2019fad1bb45d +2026-07-27-skill-catalog-hot-refresh.md: e7cff2cb53a044ed0c4789cef3550652903f3586 +2026-07-27-skill-catalog-hot-refresh.zh.md: 86519c93880f94b1b9d3bdc011aa09b04ca10686 diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index 8a53195dd8..e7cff2cb53 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -16,7 +16,7 @@ The skill capability separates catalog membership from instruction-body loading. `@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. -A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Deleting a root re-establishes ancestor observation. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. +A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. `@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 8cf5553446..86519c9388 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -16,7 +16,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps `@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 -系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 +系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 `@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index dbf3a3c0b6..4ac5f101cd 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -252,6 +252,7 @@ interface RootWatchState { } interface WatchHandle { + mode: RootWatchMode close(): Promise | void } @@ -348,9 +349,8 @@ class SkillWatchManager { private ensureWatcher(state: RootWatchState): Promise { if (this.closing || !this.config.enabled) return Promise.resolve() - if (state.watcher !== undefined && !state.unhealthy) return Promise.resolve() if (state.opening !== undefined) return state.opening - const opening = this.replaceWatcher(state) + const opening = this.ensureCurrentWatcher(state) state.opening = opening void opening.then( () => { @@ -363,6 +363,18 @@ class SkillWatchManager { return opening } + private async ensureCurrentWatcher(state: RootWatchState): Promise { + const watcher = state.watcher + if (watcher !== undefined && !state.unhealthy) { + const current = await resolveRootWatchMode(state.root.path) + // A child unlink can publish an empty catalog before root unlinkDir arrives. + // Discovery therefore revalidates the retained handle independently. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits + if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return + } + await this.replaceWatcher(state) + } + private async replaceWatcher(state: RootWatchState): Promise { const previous = state.watcher state.watcher = undefined @@ -389,7 +401,7 @@ class SkillWatchManager { } } - // FIXME(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis + // TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { @@ -416,6 +428,7 @@ class SkillWatchManager { interval: this.config.pollIntervalMs, }, listener) return { + mode, close() { unwatchFile(mode.nextPath, listener) }, @@ -436,6 +449,10 @@ class SkillWatchManager { usePolling: this.config.usePolling, interval: this.config.pollIntervalMs, }) + const handle: WatchHandle = { + mode, + close: () => watcher.close(), + } let ready = false const readiness = Promise.withResolvers() const onError = (error: unknown): void => { @@ -456,10 +473,10 @@ class SkillWatchManager { try { await readiness.promise } catch (error) { - await this.closeWatcher(watcher) + await this.closeWatcher(handle) throw error } - return watcher + return handle } private handleWatchEvent( diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index c13f4f1542..97ab8c9ee7 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' -import { mkdir, writeFile } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,13 +13,33 @@ interface FakeWatcherControl { options: Record } +interface FakeWatchFileControl { + path: string + listener(current: Stats, previous: Stats): void +} + const watcherHarness = vi.hoisted(() => ({ watchers: [] as FakeWatcherControl[], startupErrors: [] as Error[], closeErrors: 0, deferredReady: 0, + watchFiles: [] as FakeWatchFileControl[], })) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + watchFile(path: string, _options: unknown, listener: FakeWatchFileControl['listener']) { + watcherHarness.watchFiles.push({ path, listener }) + }, + unwatchFile(path: string, listener: FakeWatchFileControl['listener']) { + const index = watcherHarness.watchFiles.findIndex(control => control.path === path && control.listener === listener) + if (index !== -1) watcherHarness.watchFiles.splice(index, 1) + }, + } +}) + vi.mock('chokidar', () => ({ default: { watch(_path: unknown, options: Record) { @@ -67,6 +88,7 @@ beforeEach(() => { watcherHarness.startupErrors.length = 0 watcherHarness.closeErrors = 0 watcherHarness.deferredReady = 0 + watcherHarness.watchFiles.length = 0 }) describe('skill-local watcher failures', () => { @@ -158,6 +180,40 @@ describe('skill-local watcher failures', () => { await settle() }) + it('re-probes a retained root after child unlink and observes immediate recreation', async () => { + const home = await tempDir('skill-watch-root-reprobe') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'old-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['old-skill']) + const original = watcherHarness.watchers[0] + if (original === undefined) throw new Error('expected a root watcher') + + await rm(root, { recursive: true }) + original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md')) + await settle() + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) + + const missingRoot = watcherHarness.watchFiles.find(control => control.path === root) + expect(missingRoot).toBeDefined() + await writeSkill(root, 'recreated-skill') + missingRoot!.listener({} as Stats, {} as Stats) + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(2) }) + await settle() + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['recreated-skill']) + await fiber.dispose() + }) + it('settles an opening watcher when plugin disposal races its ready event', async () => { const home = await tempDir('skill-watch-opening-dispose') const root = join(home, '.dsh/skills') @@ -178,7 +234,7 @@ describe('skill-local watcher failures', () => { }) const discovery = provider.list({}) - await settle() + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') first.emitter.emit('unlinkDir', root) @@ -211,7 +267,7 @@ describe('skill-local watcher failures', () => { }) const discovery = provider.list({}) - await settle() + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') const disposal = provider.dispose()