fix(scope): detach drained entry generations
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user