mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(scope): bind the parent link once and gate re-linking behind the binding
setScopeParent could re-link any key from anywhere, leaving the blank-session-only recompose rule entirely to caller discipline. The relation now binds once — a second bind throws — and re-linking exists only on the ScopeParentBinding returned to the original binder, the private-capability shape the package conventions prescribe for a single-caller operation. The preset roster keeps each composed agent's binding in a WeakMap keyed by the agent, making it the sole authority that can move an agent between standing compositions; the blank-session contract itself stays with the gateway, which alone can see what a session logged.
This commit is contained in:
@@ -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 packages/core/scope/README.md
|
||||
README.md: b73f99fdffb7e3dba5e4eb31b35ff623e8f3d57c
|
||||
README.zh.md: dd54ef053a8b5d8507c5049dd924b3fbe85bf5ee
|
||||
README.md: a8fbe97ae3b59f223bb52e44860439803fda420c
|
||||
README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`setScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either.
|
||||
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either.
|
||||
|
||||
## Public API
|
||||
|
||||
- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` records the enclosing scope via `setScopeParent` before the scope is usable.
|
||||
- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Ordinarily written once at mint; re-linking an existing key is the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the caller's contract — this relation cannot see what a session logged). A link closing a cycle throws. `scopeChainOf` returns `[key, parent, …]` nearest-first.
|
||||
- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal.
|
||||
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first.
|
||||
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
|
||||
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
|
||||
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`setScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。
|
||||
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。
|
||||
|
||||
## 公开 API
|
||||
|
||||
- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `setScopeParent` 记录其外围作用域。
|
||||
- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。通常在创建时写入一次;对已有键重新认父是空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是调用方的约定——该关系看不见会话记录了什么)。会闭环的链接直接抛错。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。
|
||||
- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。
|
||||
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。
|
||||
- `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。
|
||||
- `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。
|
||||
- `Scope.dispose(): Promise<void>`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。
|
||||
|
||||
@@ -38,25 +38,49 @@ const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
|
||||
*/
|
||||
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 {
|
||||
/** The privileged handle to move one scope key's parent link. */
|
||||
export interface ScopeParentBinding {
|
||||
/**
|
||||
* Re-link the bound key to a different parent, with the same cycle check as
|
||||
* the bind. Valid only while nothing produced under the old parent is
|
||||
* retained — the blank-session recompose contract, which the holder upholds
|
||||
* because this relation cannot see what a session logged.
|
||||
* @param parent - the new enclosing scope key.
|
||||
*/
|
||||
rebind(parent: ScopeKey): void
|
||||
}
|
||||
|
||||
/** Cycle-checked write shared by the bind and every rebind. */
|
||||
function linkScopeParent(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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `parent` as `key`'s enclosing scope, once.
|
||||
*
|
||||
* A key that already has a parent throws: there is no open re-link path, so a
|
||||
* scope's ancestry cannot be moved by anyone but the original binder, who
|
||||
* alone receives the {@link ScopeParentBinding}. 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.
|
||||
* @returns the binding that alone may re-link this key.
|
||||
*/
|
||||
export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
|
||||
if (scopeParents.has(key)) {
|
||||
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
|
||||
}
|
||||
linkScopeParent(key, parent)
|
||||
return {
|
||||
rebind(next: ScopeKey): void {
|
||||
linkScopeParent(key, next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one key's enclosing scope.
|
||||
* @param key - the scope key to inspect.
|
||||
@@ -98,7 +122,7 @@ function scope(): void {}
|
||||
|
||||
/** Options accepted by {@link createScope}. */
|
||||
export interface CreateScopeOptions {
|
||||
/** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */
|
||||
/** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
|
||||
parent?: ScopeKey
|
||||
}
|
||||
|
||||
@@ -111,7 +135,7 @@ export interface CreateScopeOptions {
|
||||
* @returns the scoped context and exact/shared disposal boundaries.
|
||||
*/
|
||||
export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
|
||||
if (options?.parent !== undefined) setScopeParent(key, options.parent)
|
||||
if (options?.parent !== undefined) bindScopeParent(key, options.parent)
|
||||
const fiber = ctx.plugin(scope)
|
||||
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
||||
let disposing: Promise<void> | undefined
|
||||
@@ -134,7 +158,7 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
|
||||
/**
|
||||
* Build an opaque receiver that preserves the base filter, admits untagged
|
||||
* listeners globally, and admits tagged listeners for a matching key or any
|
||||
* of its ancestors ({@link setScopeParent}): a listener owned by an enclosing
|
||||
* of its ancestors ({@link bindScopeParent}): 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget, setScopeParent } from '@deepseek-ai/dsh-scope'
|
||||
import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -166,22 +166,30 @@ describe('scope parent chain', () => {
|
||||
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/)
|
||||
expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/)
|
||||
expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/)
|
||||
})
|
||||
|
||||
it('re-links to a different parent (the blank-session recompose path)', () => {
|
||||
it('re-links only through the binding held by the original binder', () => {
|
||||
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 })
|
||||
const binding = bindScopeParent(agent, presetA)
|
||||
createScope(ctx, agent)
|
||||
|
||||
setScopeParent(agent, presetB)
|
||||
// A bound key cannot be re-bound from the outside; only the binding moves it.
|
||||
expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/)
|
||||
binding.rebind(presetB)
|
||||
|
||||
expect(scopeChainOf(agent)).toEqual([agent, presetB])
|
||||
// The rebind keeps the cycle check: a parent may not adopt its ancestor.
|
||||
const child = { id: 'child' }
|
||||
const childBinding = bindScopeParent(child, agent)
|
||||
void childBinding
|
||||
expect(() => { binding.rebind(child) }).toThrow(/cycle/)
|
||||
})
|
||||
|
||||
it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => {
|
||||
|
||||
Reference in New Issue
Block a user