fix(skill): retain candidates across watcher failures

This commit is contained in:
Tianyi Cui
2026-07-29 21:08:10 +08:00
parent 48cf50fc55
commit 52d68a5383
23 changed files with 175 additions and 68 deletions
+2 -2
View File
@@ -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 packages/skill/skill/README.md
README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56
README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200
README.md: 9813338dcec82fc2db7e149be1bd80ec5239684d
README.zh.md: abc637c1793b31158d015468941fd38ae06254c6
+4 -4
View File
@@ -11,7 +11,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary.
- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery; candidates supplied with an incomplete observation remain in this result, which is never cached.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
@@ -28,11 +28,11 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
## Provider Contract
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
@@ -56,5 +56,5 @@ No direct prompt effect. The named consumer owns the durable initial catalog and
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism.
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
- **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state.
- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics.
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.
+4 -4
View File
@@ -11,7 +11,7 @@
### 公开 API
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方调用被拒绝或显式报告发现不完整时,`complete` 为 false;不完整观测提供的候选项仍保留在该结果中,但该结果绝不缓存。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
@@ -28,11 +28,11 @@
## 提供方契约
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
@@ -56,5 +56,5 @@
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。
- **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。
- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。
- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。
+33 -8
View File
@@ -89,12 +89,20 @@ export interface SkillLookupOptions {
/** One catalog observation plus whether every registered provider completed discovery. */
export interface SkillCatalogSnapshot {
/** Sorted model-invocable summaries from providers that completed. */
/** Sorted model-invocable summaries collected in this observation. */
readonly skills: SkillSummary[]
/** Whether every registered provider completed discovery for this observation. */
readonly complete: boolean
}
/** Provider candidates plus whether the current discovery is authoritative. */
export interface SkillProviderObservation {
/** Candidates available from the current provider discovery. */
readonly candidates: readonly SkillCandidate[]
/** Whether discovery completed and these candidates may be cached. */
readonly complete: boolean
}
/** Provider interface for one source of skills, such as local directories or a remote registry. */
export interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
@@ -105,9 +113,10 @@ export interface SkillProvider {
* authentication, and discovery are awaited inside this method. Implementations
* should settle promptly when `options.signal` aborts.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns provider candidates with precedence ranks and opaque locators.
* @returns provider candidates as a complete-array shorthand, or an explicit
* observation when usable candidates came from incomplete discovery.
*/
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
@@ -387,11 +396,9 @@ export class SkillService extends Service {
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
}
if (output === undefined) continue
if (!Array.isArray(output)) {
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
}
const listed = output as readonly SkillCandidate[]
for (const candidate of listed) {
const observation = normalizeProviderObservation(output, provider.name)
if (!observation.complete) cacheable = false
for (const candidate of observation.candidates) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
localOrder += 1
@@ -426,6 +433,24 @@ export class SkillService extends Service {
}
}
function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation {
if (Array.isArray(output)) {
return { candidates: output as readonly SkillCandidate[], complete: true }
}
if (output === null || typeof output !== 'object') {
throw invalidProviderObservation(providerName)
}
const observation = output as Partial<SkillProviderObservation>
if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') {
throw invalidProviderObservation(providerName)
}
return observation as SkillProviderObservation
}
function invalidProviderObservation(providerName: string): TypeError {
return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)
}
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
name: RUNTIME_PROVIDER,
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
+40 -10
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider, type SkillProviderObservation } from '@deepseek-ai/dsh-skill'
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
return {
@@ -169,15 +169,18 @@ describe('SkillService registry', () => {
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
const badList = new Context()
await badList.plugin(SkillService)
registerProvider(badList, {
name: 'non-array-list',
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
it('rejects malformed provider results and every malformed candidate scalar', async () => {
const malformedOutputs: unknown[] = [null, 1, {}, { candidates: [], complete: 'yes' }]
for (const [index, output] of malformedOutputs.entries()) {
const badList = new Context()
await badList.plugin(SkillService)
registerProvider(badList, {
name: `malformed-list-${index}`,
list: () => Promise.resolve(output as readonly SkillCandidate[] | SkillProviderObservation),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array or { candidates, complete } observation')
}
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
@@ -600,6 +603,33 @@ describe('SkillService registry', () => {
expect(flakyCalls).toBe(3)
})
it('keeps candidates from incomplete provider observations loadable without caching them', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let listCalls = 0
registerProvider(ctx, {
name: 'incomplete-candidates',
async list() {
listCalls += 1
return {
candidates: [{ ...memorySkill('available-skill', 'Available', 10), provider: 'incomplete-candidates' }],
complete: false,
}
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'available-skill' }],
complete: false,
})
expect((await ctx.skills.get('available-skill'))?.content).toBe('available-skill body.')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['available-skill'])
expect(listCalls).toBe(3)
})
it('invalidates only the exact registered provider and ignores its late callbacks', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)