refactor(scope,agent-presets): per-preset standing mounts over a scope parent chain
A preset is now ONE composition per process, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by having its scope key parented to the mount's. Two mechanisms in dsh-scope carry the whole change: registration views walk the parent chain (global → preset → agent, nearest shadowing farthest — ScopedLayers.chainLayers), and scoped event dispatch admits a listener tagged with an ancestor of the carrier key, which is what lets a standing composition's plan/compaction/token listeners observe each agent composed under it while a sibling preset's stay deaf. The preset plugins already key their state by Session/Agent — they predate presets and were written for the shared world — so sharing one instance is a return to their design, not a rewrite. Preset ymls are unchanged: one mount per preset means one Entry per preset, whose entry-local realms keep two presets' services apart exactly as they kept two sessions' apart before. The standing scope hangs off the service's UNTRACED context (selfCtx): a method invoked through the traceable proxy sees this.ctx rebound to the caller and carrying its shadow, and a subtree minted from that resolves every service through the shadow's fiber instead of each entry's own inject store — preset rows then fail on the very services they declare. A standing mount survives its agents deliberately. The composition a running session joined must outlive the file changing or disappearing underneath it; reclamation happens at whole-tree teardown, and file edits reach only future generations (the authoring layer swaps the pointer, never disposes a joined generation).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget, setScopeParent } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -153,3 +153,62 @@ describe('scopeTarget', () => {
|
||||
expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope parent chain', () => {
|
||||
it('links at mint, walks to the root, and rejects cycles', () => {
|
||||
const ctx = new Context()
|
||||
const preset = { kind: 'preset' }
|
||||
const agent = { kind: 'agent' }
|
||||
createScope(ctx, preset)
|
||||
createScope(ctx, agent, { parent: preset })
|
||||
|
||||
expect(scopeParentOf(agent)).toBe(preset)
|
||||
expect(scopeParentOf(preset)).toBeUndefined()
|
||||
expect(scopeChainOf(agent)).toEqual([agent, preset])
|
||||
expect(scopeChainOf(undefined)).toEqual([])
|
||||
expect(() => setScopeParent(preset, agent)).toThrow(/cycle/)
|
||||
expect(() => setScopeParent(preset, preset)).toThrow(/cycle/)
|
||||
})
|
||||
|
||||
it('re-links to a different parent (the blank-session recompose path)', () => {
|
||||
const ctx = new Context()
|
||||
const presetA = { id: 'a' }
|
||||
const presetB = { id: 'b' }
|
||||
const agent = { id: 'agent' }
|
||||
createScope(ctx, presetA)
|
||||
createScope(ctx, presetB)
|
||||
createScope(ctx, agent, { parent: presetA })
|
||||
|
||||
setScopeParent(agent, presetB)
|
||||
|
||||
expect(scopeChainOf(agent)).toEqual([agent, presetB])
|
||||
})
|
||||
|
||||
it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => {
|
||||
const ctx = new Context()
|
||||
const preset = { kind: 'preset' }
|
||||
const agent = { kind: 'agent' }
|
||||
const other = { kind: 'other-preset' }
|
||||
const presetScope = createScope(ctx, preset)
|
||||
const agentScope = createScope(ctx, agent, { parent: preset })
|
||||
const otherScope = createScope(ctx, other)
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('probe/event' as never, ((): void => { seen.push('untagged') }) as never)
|
||||
presetScope.ctx.on('probe/event' as never, ((): void => { seen.push('preset') }) as never)
|
||||
agentScope.ctx.on('probe/event' as never, ((): void => { seen.push('agent') }) as never)
|
||||
otherScope.ctx.on('probe/event' as never, ((): void => { seen.push('other') }) as never)
|
||||
|
||||
const emit = ctx as unknown as { emit: (carrier: object, type: string) => void }
|
||||
// Dispatch at the AGENT key: its own tag and its ancestor's admit; a
|
||||
// sibling root does not.
|
||||
emit.emit(scopeTarget({}, agent), 'probe/event')
|
||||
expect(seen.sort()).toEqual(['agent', 'preset', 'untagged'])
|
||||
|
||||
// Dispatch at the PRESET key: the agent-tagged listener sits BELOW the
|
||||
// dispatch key and stays excluded — events flow up the chain, not down.
|
||||
seen.length = 0
|
||||
emit.emit(scopeTarget({}, preset), 'probe/event')
|
||||
expect(seen.sort()).toEqual(['preset', 'untagged'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user