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
+28 -7
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
}