fix(skill): bound catalog discovery retries
This commit is contained in:
@@ -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: 9813338dcec82fc2db7e149be1bd80ec5239684d
|
||||
README.zh.md: 70558875a2d223367e2b35f326fba4b5d966fae3
|
||||
README.md: 65ff110999ea416648f3d676eb813dd4ceb194f8
|
||||
README.zh.md: 79e77d2a2846489125ba0ecaafc138172ba2fd86
|
||||
|
||||
@@ -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 rejects or explicitly reports incomplete discovery; candidates supplied with an incomplete observation remain in this result, which is never cached.
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that 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.
|
||||
@@ -32,7 +32,7 @@ A provider factory runs synchronously and receives one registration-scoped contr
|
||||
|
||||
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 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.
|
||||
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 once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. 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.
|
||||
|
||||
|
||||
@@ -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,以供有序组合拆卸。
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
|
||||
|
||||
违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并在返回前重试。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
|
||||
违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
|
||||
|
||||
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type Schema from 'schemastery'
|
||||
|
||||
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
|
||||
const MAX_COLLECT_ATTEMPTS = 2
|
||||
const RUNTIME_PROVIDER = 'runtime'
|
||||
const RUNTIME_RANK = 250
|
||||
|
||||
@@ -87,11 +88,11 @@ export interface SkillLookupOptions {
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/** One catalog observation plus whether every registered provider completed discovery. */
|
||||
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
|
||||
export interface SkillCatalogSnapshot {
|
||||
/** Sorted model-invocable summaries collected in this observation. */
|
||||
readonly skills: SkillSummary[]
|
||||
/** Whether every registered provider completed discovery for this observation. */
|
||||
/** Whether every registered provider completed without a concurrent catalog revision. */
|
||||
readonly complete: boolean
|
||||
}
|
||||
|
||||
@@ -286,11 +287,11 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the current model-invocable catalog and whether all providers completed discovery.
|
||||
* Observe the current model-invocable catalog and whether discovery completed within a stable revision.
|
||||
* Incomplete observations are never cached, allowing consumers to retain last-good state and
|
||||
* retry on their next request boundary.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries plus provider-completeness state.
|
||||
* @returns sorted summaries plus discovery-completeness state.
|
||||
*/
|
||||
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
|
||||
const collected = await this.collect(options)
|
||||
@@ -333,6 +334,7 @@ export class SkillService extends Service {
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
throwIfAborted(options.signal)
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
@@ -342,7 +344,13 @@ export class SkillService extends Service {
|
||||
|
||||
const result = await this.collectFresh(options)
|
||||
throwIfAborted(options.signal)
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) {
|
||||
if (attempt < MAX_COLLECT_ATTEMPTS) {
|
||||
attempt += 1
|
||||
continue
|
||||
}
|
||||
return { entries: result.entries, cacheable: false }
|
||||
}
|
||||
if (result.cacheable) {
|
||||
this.collectCache.set(key, result.entries)
|
||||
if (this.collectCache.size > this.collectCacheMaxEntries) {
|
||||
|
||||
@@ -748,6 +748,40 @@ describe('SkillService registry', () => {
|
||||
expect(provider.listCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('bounds repeated in-flight invalidation and leaves the result uncached', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let listCalls = 0
|
||||
ctx.skills.registerProvider(control => ({
|
||||
name: 'self-invalidating',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
control.invalidate()
|
||||
return [{
|
||||
...memorySkill('bounded-skill', `Attempt ${listCalls}`, 10),
|
||||
provider: 'self-invalidating',
|
||||
}]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
}))
|
||||
|
||||
expect(await ctx.skills.snapshot()).toEqual({
|
||||
skills: [{
|
||||
name: 'bounded-skill',
|
||||
description: 'Attempt 2',
|
||||
provider: 'self-invalidating',
|
||||
source: 'memory',
|
||||
}],
|
||||
complete: false,
|
||||
})
|
||||
expect(listCalls).toBe(2)
|
||||
|
||||
expect((await ctx.skills.snapshot()).skills[0]?.description).toBe('Attempt 4')
|
||||
expect(listCalls).toBe(4)
|
||||
})
|
||||
|
||||
it('invalidates a provider whose loaded definition changed identity', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
|
||||
Reference in New Issue
Block a user