fix(scope): detach drained entry generations

This commit is contained in:
Tianyi Cui
2026-07-22 12:06:42 +08:00
parent f90b64ac26
commit 0180cdecd0
9 changed files with 103 additions and 27 deletions
@@ -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
2026-07-12-scoped-layers-store.md: 5ba91f33eb079a44f966d7f0b5ff097ff529e700
2026-07-12-scoped-layers-store.zh.md: c84792e468a435e2463b5ff682fad8a8129189ed
2026-07-12-scoped-layers-store.md: b850b6bcbb22401b386b4458b6d5c65a160c85cd
2026-07-12-scoped-layers-store.zh.md: 8bfc0a0e8ec1e3de624ff8d9e48b7517833fc025
@@ -74,17 +74,17 @@ export class AnonymousEntries<V> {
- The constructor creates `global` once with `createLayer(undefined)`. A scoped layer is created only by `effect()`; `peek()` and `merge()` never create one, and `peek(undefined)` returns `undefined` because the global layer is already explicit.
- `merge()` is the only materialized generic read. It copies named global entries in insertion order, then applies matching scoped entries in their insertion order so same-name entries shadow without moving unrelated names.
- `NamedEntries.insert()` checks and inserts atomically, returns an idempotent exact-entry undo, and obtains the registry's exact duplicate diagnostic from the caller-supplied factory. Lookup and iterators retain native `Map` order and live iteration.
- `AnonymousEntries.append()` assigns a unique internal key per registration, so equal callbacks or values remain independent. Its iterator is live and insertion-ordered.
- `NamedEntries.insert()` checks and inserts atomically, returns an idempotent exact-entry undo, and obtains the registry's exact duplicate diagnostic from the caller-supplied factory. Lookup and iterators retain native `Map` order and stay live within one nonempty table generation; draining the table starts a new generation so an in-flight iterator cannot observe a self-replacement.
- `AnonymousEntries.append()` assigns a unique internal key per registration, so equal callbacks or values remain independent. Its iterator is insertion-ordered and uses the same live-generation boundary.
- `effect()` derives the key with `scopeOf(ctx)` and attaches the action to that same `ctx.effect()`. It accepts one synchronous action returning one synchronous undo; actions must either return their undo or throw before retaining a contribution. The helper does not normalize the wider Cordis `Effect` union.
- `effect()` collects the action's undo before calling `onChange` and returns the exact `ctx.effect()` disposer. Disposal runs the action undo before notification, is idempotent through Cordis, and removes a scoped layer only after its complete `ScopeLayer.isEmpty()` becomes true.
- `options.notify` defaults to `true`. The callback's own policy stays authoritative: tool and prompt change callbacks may throw and trigger registration rollback; `CommandService.notifyChange()` contains observer failures; tool guards pass `notify: false`.
## Registry migrations
`dsh-tools` defines one `ToolLayer` containing named tools plus anonymous compiled restrictions and guard registrations. `ToolRegistry` retains its private domain resolver for visible definitions, pre-restriction known names, restrictable global names, scoped shadowing, restrictions, and reserved `run_code` insertion. Guard evaluation continues to live-iterate global then scoped registrations so re-entrant additions retain current behavior.
`dsh-tools` defines one `ToolLayer` containing named tools plus anonymous compiled restrictions and guard registrations. `ToolRegistry` retains its private domain resolver for visible definitions, pre-restriction known names, restrictable global names, scoped shadowing, restrictions, and reserved `run_code` insertion. Guard evaluation live-iterates global then scoped registrations: additions to a nonempty generation can run in the current dispatch, while a self-replacement after draining the guard table begins with the next dispatch.
`dsh-system-prompt` defines one `PromptLayer` containing named sections and variables plus anonymous tool providers. Assembly merges sections before evaluating them, so a shadowed provider is never called. Tool-provider membership is materialized once per assembly. Variable providers continue to live-iterate global then scoped tables, preserving re-entrant registration behavior.
`dsh-system-prompt` defines one `PromptLayer` containing named sections and variables plus anonymous tool providers. Assembly merges sections before evaluating them, so a shadowed provider is never called. Tool-provider membership is materialized once per assembly. Variable providers live-iterate global then scoped tables: additions to a nonempty generation can run in the current assembly, while a self-replacement after draining the variable table begins with the next assembly.
`dsh-commands` defines a one-table layer containing `NamedEntries<RegisteredCommand>`. Effective views use `merge()`, while `CommandService` retains definition normalization and freezing, exact duplicate diagnostics, sorted immutable descriptors, direct execution, HMR cleanup, and independently contained `commands/change` observers.
@@ -120,7 +120,7 @@ All seven facades keep validation and diagnostics in their owning registry and c
## Verification
- `dsh-scope` unit tests cover global construction, lazy scoped construction, non-creating reads, named merge order and shadowing, aggregate reclamation, factory and action failure cleanup, notification ordering and rollback, `notify: false`, effect labels, exact disposer identity, idempotent teardown, caller-owned duplicate errors, independent anonymous duplicates, and live iterators.
- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal.
- `dsh-scope` unit tests cover global construction, lazy scoped construction, non-creating reads, named merge order and shadowing, aggregate reclamation, factory and action failure cleanup, notification ordering and rollback, `notify: false`, effect labels, exact disposer identity, idempotent teardown, caller-owned duplicate errors, independent anonymous duplicates, live iterators, and drained-generation detachment.
- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy and self-replacement, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy and self-replacement, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal.
- The scoped core-data type-equivalence check ties `ScopeLayer` documentation to its source declaration. Repository documentation, module-graph, build, hygiene, coverage, and built-artifact gates exercise the root export and package boundary.
- Existing ACP, headless, and TUI keyless snapshots remain the regression boundary for tool schemas, prompt assembly, and human commands. The implementation does not update any expected transcript.
@@ -74,17 +74,17 @@ export class AnonymousEntries<V> {
- 构造器只创建一次 `global`,调用的是 `createLayer(undefined)`。只有 `effect()` 会创建专属层;`peek()` 和 `merge()` 从不创建专属层,而 `peek(undefined)` 返回 `undefined`,因为全局层已经显式存在。
- `merge()` 是唯一会物化结果的通用读取接口。它按插入顺序复制全局命名条目,再按专属条目的插入顺序应用这些条目;同名条目完成遮蔽,但不会移动无关名称。
- `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序和活遍历语义
- `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器保留插入顺序的活迭代器
- `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序,并在同一个非空表 generation 内保持活遍历;清空表会开启新的 generation,因此尚未结束的迭代器无法观察到自我替换
- `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器保留插入顺序,并采用同样的 generation 活遍历边界
- `effect()` 通过 `scopeOf(ctx)` 导出键,并把 action 挂到同一个 `ctx.effect()` 上。它只接受一个同步 action,且该 action 只返回一个同步 undoaction 要么返回其 undo,要么必须在保留任何贡献之前抛错。helper 不会规范化更宽泛的 Cordis `Effect` union。
- `effect()` 在调用 `onChange` 前收集 action 的 undo,并原样返回 `ctx.effect()` 的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有整个层的 `ScopeLayer.isEmpty()` 变为 true 后,helper 才删除专属层。
- `options.notify` 默认为 `true`。回调自身的策略仍具最终效力:工具与提示词的 change 回调可以抛错并触发登记回滚;`CommandService.notifyChange()` 会隔离观察者失败;工具 guard 传入 `notify: false`。
## 注册表迁移
`dsh-tools` 定义一个 `ToolLayer`,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。`ToolRegistry` 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 `run_code` 插入。guard 求值继续先活遍历全局登记,再活遍历专属登记,因此重入时新增的登记保持现有行为
`dsh-tools` 定义一个 `ToolLayer`,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。`ToolRegistry` 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 `run_code` 插入。guard 求值先活遍历全局登记,再活遍历专属登记:向非空 generation 新增的登记可以在当前分发中运行,而 guard 表清空后的自我替换则从下一次分发开始运行
`dsh-system-prompt` 定义一个 `PromptLayer`,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方继续先活遍历全局表,再活遍历专属表,从而保留重入登记行为
`dsh-system-prompt` 定义一个 `PromptLayer`,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方先活遍历全局表,再活遍历专属表:向非空 generation 新增的提供方可以在当前组装中运行,而变量表清空后的自我替换则从下一次组装开始运行
`dsh-commands` 定义一个单表层,其中包含 `NamedEntries<RegisteredCommand>`。生效视图使用 `merge()``CommandService` 则保留对定义的规范化与冻结处理、精确重名诊断、经过排序的不可变描述符、直接执行、HMR(热模块替换)清理,以及对各个 `commands/change` 观察者分别隔离失败的行为。
@@ -120,7 +120,7 @@ export class AnonymousEntries<V> {
## 验证
- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记活迭代器。
- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。
- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记活迭代器,以及表清空后的 generation 脱离
- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。
- 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。
- 现有 ACPAgent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema、提示词组装和人类命令的回归边界。实现不会更新任何预期 transcript(文本记录)。
+1 -1
View File
@@ -54,4 +54,4 @@ interface ScopeLayer {
`ScopedLayers<L>` owns the eager global layer and lazily created exact-scope layers. Reads do not create layers: `peek(undefined)` means no overlay, while `merge()` materializes insertion-ordered global named entries followed by scoped shadows. Registrations use one context for both visibility and Cordis effect ownership, collect one synchronous undo before optional notification, return Cordis's exact disposer, and reclaim a scoped layer only when its complete `ScopeLayer` is empty.
`NamedEntries<V>` supplies live insertion-ordered lookup and iteration with caller-owned duplicate errors. `AnonymousEntries<V>` gives every append a unique identity so equal values remain independent. Both return idempotent exact-entry undos; the shared `EntryValues` implementation interface is not public.
`NamedEntries<V>` supplies insertion-ordered lookup and live iteration with caller-owned duplicate errors. `AnonymousEntries<V>` gives every append a unique identity so equal values remain independent. Iteration stays live within one nonempty table generation; draining the table detaches existing iterators from later insertions. Both return idempotent exact-entry undos; the shared `EntryValues` implementation interface is not public.
+2 -2
View File
@@ -14,8 +14,8 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, live lookup/iteration, and an idempotent exact-entry undo from `insert()`.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; `append()` returns an idempotent exact-entry undo.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
+16 -10
View File
@@ -23,11 +23,12 @@ interface EntryValues<V> {
/**
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
*
* Values are borrowed. Iterators are live native `Map` iterators, and each
* Values are borrowed. Iterators are live within one nonempty table
* generation; draining the table detaches them from later insertions. Each
* successful insertion returns an idempotent undo for that exact entry.
*/
export class NamedEntries<V> implements EntryValues<V> {
private readonly data = new Map<string, V>()
private data = new Map<string, V>()
constructor(
private readonly duplicateError: (name: string) => Error,
@@ -40,13 +41,15 @@ export class NamedEntries<V> implements EntryValues<V> {
* @returns an idempotent undo that removes only this insertion.
*/
insert(name: string, value: V): () => void {
if (this.data.has(name)) throw this.duplicateError(name)
this.data.set(name, value)
const data = this.data
if (data.has(name)) throw this.duplicateError(name)
data.set(name, value)
let active = true
return () => {
if (!active) return
active = false
this.data.delete(name)
data.delete(name)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
@@ -104,11 +107,12 @@ export class NamedEntries<V> implements EntryValues<V> {
/**
* Insertion-ordered anonymous entries with independent registration identity.
*
* Equal values remain separate registrations. Values are borrowed and the
* returned iterator retains native live `Map` semantics.
* Equal values remain separate registrations. Values are borrowed, and
* iterators are live within one nonempty table generation; draining the table
* detaches them from later appends.
*/
export class AnonymousEntries<V> implements EntryValues<V> {
private readonly data = new Map<symbol, V>()
private data = new Map<symbol, V>()
/**
* Append one independently owned value.
@@ -116,13 +120,15 @@ export class AnonymousEntries<V> implements EntryValues<V> {
* @returns an idempotent undo for this exact append.
*/
append(value: V): () => void {
const data = this.data
const key = Symbol()
this.data.set(key, value)
data.set(key, value)
let active = true
return () => {
if (!active) return
active = false
this.data.delete(key)
data.delete(key)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
+26
View File
@@ -59,6 +59,19 @@ describe('NamedEntries', () => {
undoB()
expect([...entries.entries()]).toEqual([['a', 3]])
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
const undo = entries.insert('first', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.insert('replacement', 2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('AnonymousEntries', () => {
@@ -78,6 +91,19 @@ describe('AnonymousEntries', () => {
undoSecond()
expect(entries.isEmpty()).toBe(true)
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new AnonymousEntries<number>()
const undo = entries.append(1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.append(2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('ScopedLayers', () => {
@@ -101,6 +101,28 @@ describe('scoped variables', () => {
const again = await mintScope(ctx, 'child2')
again.ctx.systemPrompt.variable('v', () => '3')
})
it('defers a scoped variable that replaces the last provider in its generation', async () => {
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
const calls: string[] = []
scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
const dispose = scope.ctx.systemPrompt.variable('mode', () => {
calls.push('first')
dispose()
scope.ctx.systemPrompt.variable('mode', () => {
calls.push('replacement')
return 'replacement'
})
return 'first'
})
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
expect(calls).toEqual(['first'])
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
expect(calls).toEqual(['first', 'replacement'])
})
})
describe('scoped tool providers and toolOrder × restriction', () => {
+22
View File
@@ -287,6 +287,28 @@ describe('scoped execution dispatch', () => {
expect(calls).toEqual(['first', 'late'])
})
it('defers a scoped guard that replaces the last guard in its generation', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const calls: string[] = []
ctx.tools.register(tool('t'))
scope.ctx.tools.register(tool('scope_sibling'))
const lift = scope.ctx.tools.guard(() => {
calls.push('first')
lift()
scope.ctx.tools.guard(() => {
calls.push('replacement')
return 'replacement denial'
})
return undefined
})
expect(await run(ctx, 't', key)).toBe('ran:t')
expect(calls).toEqual(['first'])
expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
expect(calls).toEqual(['first', 'replacement'])
})
it('shares one token and materialized argument value across the pipeline', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')