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:
Yichen Jiang
2026-08-08 17:51:49 +08:00
parent 2afdc68fab
commit e18aa2745c
8 changed files with 275 additions and 35 deletions

View File

@@ -29,6 +29,54 @@ export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
/**
* The enclosing scope of each key. One relation powers both directions of
* scope nesting: registration views inherit DOWN the chain (a child scope
* sees its ancestors' layers — {@link ScopedLayers}), and event admission
* extends UP it (a listener tagged with an ancestor receives events dispatched
* to a descendant key — {@link scopeTarget}).
*/
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
/**
* Record `parent` as `key`'s enclosing scope.
*
* Ordinarily set once when the child scope is minted ({@link createScope}'s
* `parent` option). Re-linking an existing key to a different parent is the
* blank-session recompose operation: valid only while nothing produced under
* the old parent is retained, which is the caller's contract to uphold — this
* relation cannot see what a session logged. A link that would close a cycle
* is rejected, because every chain consumer walks parents to the root.
* @param key - the child scope key.
* @param parent - its enclosing scope key.
*/
export function setScopeParent(key: ScopeKey, parent: ScopeKey): void {
for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle')
}
scopeParents.set(key, parent)
}
/**
* Read one key's enclosing scope.
* @param key - the scope key to inspect.
* @returns its parent key, or `undefined` for a root scope.
*/
export function scopeParentOf(key: ScopeKey): ScopeKey | undefined {
return scopeParents.get(key)
}
/**
* The chain from a key to its root ancestor.
* @param key - the starting key, or `undefined` for the empty chain.
* @returns keys nearest-first: `[key, parent, grandparent, …]`.
*/
export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] {
const chain: ScopeKey[] = []
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor)
return chain
}
/** A minted registration scope and its quiescent disposal boundaries. */
export interface Scope {
/** Context through which scope-owned registrations are made. */
@@ -48,14 +96,22 @@ async function quiesceFiber(fiber: Fiber): Promise<void> {
/** Shared no-op plugin used as the backing scope fiber. */
function scope(): void {}
/** Options accepted by {@link createScope}. */
export interface CreateScopeOptions {
/** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */
parent?: ScopeKey
}
/**
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's
* dependency surface and owns every registration made through it.
* @param ctx - active context whose dependency surface the scope inherits.
* @param key - opaque identity used for listener routing.
* @param options - optional scope-chain placement.
* @returns the scoped context and exact/shared disposal boundaries.
*/
export function createScope(ctx: Context, key: ScopeKey): Scope {
export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
if (options?.parent !== undefined) setScopeParent(key, options.parent)
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
let disposing: Promise<void> | undefined
@@ -77,7 +133,12 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
/**
* Build an opaque receiver that preserves the base filter, admits untagged
* listeners globally, and admits tagged listeners only for a matching key.
* listeners globally, and admits tagged listeners for a matching key or any
* of its ancestors ({@link setScopeParent}): a listener owned by an enclosing
* scope receives every descendant scope's events, which is what lets one
* standing composition observe each of the agents composed under it. A tag
* BELOW the dispatch key stays excluded — events flow up the chain, never
* down.
* @param base - subject or service whose existing Cordis filter is preserved.
* @param key - routed scope identity, or `undefined` for an unscoped subject.
* @returns a carrier whose subject remains available only through event arguments.
@@ -88,7 +149,11 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
[CordisContext.filter](ctx: Context): boolean {
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
const tag = scopeOf(ctx)
return tag === undefined || tag === key
if (tag === undefined) return true
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === tag) return true
}
return false
},
}
carrierKeys.set(carrier, key)

View File

@@ -5,7 +5,7 @@
*/
import type { Context } from 'cordis'
import { scopeOf } from './index.ts'
import { scopeChainOf, scopeOf } from './index.ts'
import type { ScopeKey } from './index.ts'
/** One scope's aggregate contribution to a registry. */
@@ -170,7 +170,10 @@ export class ScopedLayers<L extends ScopeLayer> {
}
/**
* Read an existing exact-scope overlay.
* Read an existing exact-scope overlay. Deliberately chain-blind: callers
* addressing one scope's OWN contributions (its restrictions, its guards)
* must not silently pick up an ancestor's — use {@link chainLayers} where
* inheritance is the point.
* @param scope - exact scope key; `undefined` denotes no overlay.
* @returns the existing scoped layer, or `undefined` without creating one.
*/
@@ -180,8 +183,26 @@ export class ScopedLayers<L extends ScopeLayer> {
}
/**
* Materialize global named entries followed by exact-scope shadows.
* @param scope - exact viewing scope, or `undefined` for the global view.
* Existing overlays along the scope's parent chain ({@link scopeChainOf}),
* farthest ancestor first and the exact scope last, so a caller layering
* them in order gives the nearest scope the final word.
* @param scope - viewing scope, or `undefined` for no overlays.
* @returns the existing layers, nearest last; absent overlays are skipped.
*/
chainLayers(scope: ScopeKey | undefined): L[] {
const chain = scopeChainOf(scope)
const layers: L[] = []
for (let index = chain.length - 1; index >= 0; index -= 1) {
const layer = this.scoped.get(chain[index]!)
if (layer !== undefined) layers.push(layer)
}
return layers
}
/**
* Materialize global named entries followed by scope-chain shadows,
* farthest ancestor first, so the nearest scope's entry wins a name.
* @param scope - viewing scope, or `undefined` for the global view.
* @param pick - select the named table from a layer.
* @returns an insertion-ordered effective map.
*/
@@ -190,9 +211,9 @@ export class ScopedLayers<L extends ScopeLayer> {
pick: (layer: L) => NamedEntries<V>,
): Map<string, V> {
const merged = new Map(pick(this.global).entries())
const layer = this.peek(scope)
if (layer === undefined) return merged
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
for (const layer of this.chainLayers(scope)) {
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
}
return merged
}

View File

@@ -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'])
})
})