Kaynağa Gözat

fix(core): make initiator teardown reentrant-safe

Tianyi Cui 2 ay önce
ebeveyn
işleme
ee1a44793a

+ 5 - 1
docs/cordis-catalog/services.md

@@ -68,6 +68,8 @@ requireInitiator(): Agent
 /**
  * Run an operation with one exact Agent as its process-local initiator. The
  * exact synchronous value or Promise returned by the operation is preserved.
+ * If its inherited async chain starts an owning-fiber unload, the nested
+ * boundary lineage is excluded from the drain so teardown cannot wait on itself.
  * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.
  * @param operation - synchronous or asynchronous operation to invoke.
  * @returns the exact value returned by `operation`.
@@ -78,6 +80,8 @@ withInitiator<T>(agent: Agent, operation: () => T): T
 /**
  * Run an operation inside a boundary that hides any inherited initiating
  * Agent. The exact synchronous value or Promise is preserved.
+ * If its inherited async chain starts an owning-fiber unload, the nested
+ * boundary lineage is excluded from the drain so teardown cannot wait on itself.
  * @param operation - synchronous or asynchronous operation to invoke without an initiator.
  * @returns the exact value returned by `operation`.
  * @throws when the initiator scope is closing/disposed, or when `operation` throws.
@@ -198,7 +202,7 @@ roots(): Agent[]
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/index.ts:204`](../../packages/core/agent/src/index.ts)
+Source: [`packages/core/agent/src/index.ts:211`](../../packages/core/agent/src/index.ts)
 
 ## `ctx.approval` — `ApprovalService`
 

+ 1 - 0
docs/event-producer-consumer.md

@@ -53,5 +53,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | `internal/dispatch` | - | [`invariants`](../packages/support/invariants) |
+| `internal/status` | - | [`agent`](../packages/core/agent) |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 2 - 2
docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml

@@ -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
-2026-07-15-agent-initiator-scope.md: 08c9eca50ed51925fec7c09eda7a82d0c61cb55b
-2026-07-15-agent-initiator-scope.zh.md: 284d52126e02e44d512b88a5d64e35ba80552d9b
+2026-07-15-agent-initiator-scope.md: b3c9be0be1dea29568dfcdeb0578e643734486e8
+2026-07-15-agent-initiator-scope.zh.md: 55494977b8ade0d380fa21b25171bce65a46a9fb

+ 3 - 3
docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md

@@ -12,7 +12,7 @@ Deep process-local infrastructure sometimes needs a trusted initiating Agent bel
 
 ## Decision
 
-The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the initiating Agent. It stores the exact `Agent` directly rather than introducing a one-field frame; the [core-data catalog](../../../core-data-structures/core.md#initiating-agent) identifies the carried type.
+The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the initiating Agent. It stores the exact `Agent` directly rather than introducing a one-field frame; a separate private run token records nested boundary lineage only for teardown bookkeeping and carries no identity. The [core-data catalog](../../../core-data-structures/core.md#initiating-agent) identifies the carried type.
 
 `currentInitiator()` reads optionally, `requireInitiator()` throws `no initiating agent is active`, and `withInitiator(agent, operation)` preserves the operation's exact synchronous value or Promise. `withoutInitiator(operation)` establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as `agent.session`; turn, step, tool call, `signal`, model, `cwd`, sandbox, and authorization stay with their existing owners.
 
@@ -20,7 +20,7 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in
 
 Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
 
-`AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while that drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering.
+`AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering.
 
 Initiator scope does not own detached work: registry drain tracks only the Promise returned by `withInitiator()` or `withoutInitiator()`. Asynchronous resources created inside a boundary inherit its store until they settle or ALS is disabled, so their owning seam must stop unreturned work explicitly. Agent-owned foreground work returns its lifetime and keeps its cancellation contract. Unrelated timers, queues, and deployment infrastructure start under `withoutInitiator(operation)`; queue, worker, process, and wire boundaries serialize identity rather than expecting ALS propagation.
 
@@ -30,7 +30,7 @@ This decision extends the [Agent registration-scope contract](2026-07-08-agent-s
 
 ## Verification
 
-Agent service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, overlapping, nested, and cleared boundaries, restoration after throws or rejection, drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, AgentRegistry restart, and root teardown. Composition, module-graph, build, and runtime-closure checks keep `ctx.agents` wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses without another provider.
+Agent service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, intrinsic Promise settlement observation, overlapping, nested, and cleared boundaries, restoration after throws or rejection, ordinary and reentrant drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, AgentRegistry restart, and root teardown. Composition, module-graph, build, and runtime-closure checks keep `ctx.agents` wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses without another provider.
 
 Only a test-double host-aware transport consumes ambient identity; it derives `X-Harness-Session-Id` internally and verifies that tool schema and logged arguments contain no identity field. The service deliberately does not drain async work omitted from the Promise returned by the boundary operation; that work remains subject to its owner's explicit stop contract.
 

+ 3 - 3
docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md

@@ -12,7 +12,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
 
 ## 决策
 
-必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;[核心数据目录](../../../core-data-structures/core.md#initiating-agent)标明了所携带的类型。
+必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../core-data-structures/core.md#initiating-agent)标明了所携带的类型。
 
 `currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。
 
@@ -20,7 +20,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
 
 隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
 
-`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose 后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。
+`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose 后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。
 
 发起方作用域不负责管理脱离返回链的工作:注册表排空只跟踪 `withInitiator()` 或 `withoutInitiator()` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所有前台工作会把完整生命周期纳入返回值,并保留显式取消契约。无关的定时器、队列和部署基础设施在 `withoutInitiator(operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。
 
@@ -30,7 +30,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
 
 ## 验证
 
-Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启及根 Context 销毁。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。
+Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启及根 Context 销毁。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。
 
 只有测试替身形式的宿主感知传输层消费隐式身份;它在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。
 

+ 1 - 1
packages/core/agent/README.md

@@ -26,7 +26,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
 - `ctx.agents.withInitiator(agent, operation)` — run with one exact Agent and preserve the operation's exact synchronous value or Promise.
 - `ctx.agents.withoutInitiator(operation)` — hide an inherited initiator for unrelated process-local work.
 
-The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract.
+The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. If a boundary's inherited async chain starts an owning Cordis fiber's unload, that nested boundary chain is released from the drain so the unload cannot wait on itself; its continuations observe the disposed service after teardown. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract.
 
 #### Factory seam (creation)
 

+ 61 - 9
packages/core/agent/src/index.ts

@@ -5,7 +5,8 @@
  * @module @deepseek-ai/dsh-agent
  */
 
-import { Context, getTraceable, Service, symbols } from 'cordis'
+import { Context, FiberState, getTraceable, Service, symbols } from 'cordis'
+import type { Fiber } from 'cordis'
 import { AsyncLocalStorage } from 'node:async_hooks'
 import { isPromise } from 'node:util/types'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
@@ -190,6 +191,12 @@ interface AgentEntry {
   detachRequested: boolean
 }
 
+/** One tracked boundary plus its inherited nesting chain. */
+interface InitiatorRun {
+  active: boolean
+  readonly parent: InitiatorRun | undefined
+}
+
 /** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */
 interface FactorySlot {
   readonly target: AgentFactory
@@ -205,6 +212,7 @@ export class AgentRegistry extends Service {
   private store = new Map<SessionId, AgentEntry>()
   private factory: FactorySlot | undefined
   private readonly initiators = new AsyncLocalStorage<Agent | undefined>()
+  private readonly initiatorRuns = new AsyncLocalStorage<InitiatorRun>()
   private initiatorState: 'active' | 'closing' | 'disposed' = 'active'
   private activeInitiatorRuns = 0
   private initiatorDrain: PromiseWithResolvers<void> | undefined
@@ -219,6 +227,11 @@ export class AgentRegistry extends Service {
     // accessor body never needs to resolve a scope itself. Effect-scoped:
     // unwinds with this service's fiber.
     ctx.accessor('agent', { get: () => undefined })
+    ctx.on('internal/status', (fiber) => {
+      if (fiber.state === FiberState.UNLOADING && this.hasLifecycleAncestor(fiber)) {
+        this.closeInitiators()
+      }
+    })
     ctx.effect(function* (this: AgentRegistry) {
       yield () => this.disposeInitiators()
       yield () => { this.closeInitiators() }
@@ -249,6 +262,8 @@ export class AgentRegistry extends Service {
   /**
    * Run an operation with one exact Agent as its process-local initiator. The
    * exact synchronous value or Promise returned by the operation is preserved.
+   * If its inherited async chain starts an owning-fiber unload, the nested
+   * boundary lineage is excluded from the drain so teardown cannot wait on itself.
    * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.
    * @param operation - synchronous or asynchronous operation to invoke.
    * @returns the exact value returned by `operation`.
@@ -261,6 +276,8 @@ export class AgentRegistry extends Service {
   /**
    * Run an operation inside a boundary that hides any inherited initiating
    * Agent. The exact synchronous value or Promise is preserved.
+   * If its inherited async chain starts an owning-fiber unload, the nested
+   * boundary lineage is excluded from the drain so teardown cannot wait on itself.
    * @param operation - synchronous or asynchronous operation to invoke without an initiator.
    * @returns the exact value returned by `operation`.
    * @throws when the initiator scope is closing/disposed, or when `operation` throws.
@@ -537,42 +554,77 @@ export class AgentRegistry extends Service {
   private disposeInitiators(): Promise<void> {
     return (this.initiatorDisposal ??= (async () => {
       this.closeInitiators()
+      this.releaseReentrantInitiatorRuns()
       if (this.activeInitiatorRuns !== 0) {
         this.initiatorDrain ??= Promise.withResolvers<void>()
         await this.initiatorDrain.promise
       }
       this.initiatorState = 'disposed'
       this.initiators.disable()
+      this.initiatorRuns.disable()
     })())
   }
 
   /** Establish one tracked initiator or clearing boundary. */
   private runWithInitiator<T>(agent: Agent | undefined, operation: () => T): T {
     if (this.initiatorState !== 'active') throw new Error(DISPOSED_INITIATOR_MESSAGE)
+    const run: InitiatorRun = {
+      active: true,
+      parent: this.initiatorRuns.getStore(),
+    }
     this.activeInitiatorRuns += 1
     let result: T
     try {
-      result = this.initiators.run(agent, operation)
+      result = this.initiatorRuns.run(run, () => this.initiators.run(agent, operation))
     } catch (error: unknown) {
-      this.releaseInitiatorRun()
+      this.releaseInitiatorRun(run)
       throw error
     }
     if (isPromise(result)) {
-      void result.then(
-        () => { this.releaseInitiatorRun() },
-        () => { this.releaseInitiatorRun() },
-      )
+      try {
+        void Promise.prototype.then.call(
+          result,
+          () => { this.releaseInitiatorRun(run) },
+          () => { this.releaseInitiatorRun(run) },
+        )
+      } catch {
+        // A branded Promise may expose a failing @@species. Observer setup did
+        // not attach, so preserve the exact return without leaking the run.
+        this.releaseInitiatorRun(run)
+      }
     } else {
-      this.releaseInitiatorRun()
+      this.releaseInitiatorRun(run)
     }
     return result
   }
 
+  /** Whether one unloading fiber owns this service's lifecycle. */
+  private hasLifecycleAncestor(candidate: Fiber): boolean {
+    let fiber = this.ctx.fiber
+    while (true) {
+      if (fiber === candidate) return true
+      const parent = fiber.parent.fiber
+      if (parent === fiber) return false
+      fiber = parent
+    }
+  }
+
   private assertInitiatorsReadable(): void {
     if (this.initiatorState === 'disposed') throw new Error(DISPOSED_INITIATOR_MESSAGE)
   }
 
-  private releaseInitiatorRun(): void {
+  /** Exclude the boundary chain that initiated this teardown from its own drain. */
+  private releaseReentrantInitiatorRuns(): void {
+    let run = this.initiatorRuns.getStore()
+    while (run !== undefined) {
+      this.releaseInitiatorRun(run)
+      run = run.parent
+    }
+  }
+
+  private releaseInitiatorRun(run: InitiatorRun): void {
+    if (!run.active) return
+    run.active = false
     this.activeInitiatorRuns -= 1
     if (this.activeInitiatorRuns !== 0) return
     this.initiatorDrain?.resolve()

+ 102 - 0
packages/core/agent/tests/agent-initiator.spec.ts

@@ -23,6 +23,17 @@ async function harness(): Promise<{
   }
 }
 
+/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
+async function promptly<T>(task: Promise<T>): Promise<T> {
+  const timeout = Promise.withResolvers<never>()
+  const timer = setTimeout(() => { timeout.reject(new Error('initiator teardown did not settle promptly')) }, 1000)
+  try {
+    return await Promise.race([task, timeout.promise])
+  } finally {
+    clearTimeout(timer)
+  }
+}
+
 describe('AgentRegistry initiator scope', () => {
   it('reports an absent initiator and requires an active boundary', async () => {
     const { service, dispose } = await harness()
@@ -52,6 +63,44 @@ describe('AgentRegistry initiator scope', () => {
     await dispose()
   })
 
+  it('tracks a branded Promise without calling its overridable then property', async () => {
+    const { service, dispose } = await harness()
+    const initiator = agent('overridden-then')
+    const release = Promise.withResolvers<boolean>()
+    void Object.defineProperty(release.promise, 'then', {
+      value: () => { throw new Error('overridden then called') },
+    })
+
+    const pending = service.withInitiator(initiator, () => release.promise)
+    expect(pending).toBe(release.promise)
+
+    let disposed = false
+    const disposal = dispose().then(() => { disposed = true })
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+
+    release.resolve(true)
+    await new Promise<void>((resolve, reject) => {
+      void Promise.prototype.then.call(pending, resolve, reject)
+    })
+    await disposal
+    expect(disposed).toBe(true)
+  })
+
+  it('preserves a settled branded Promise when its species blocks observer construction', async () => {
+    const { service, dispose } = await harness()
+    const initiator = agent('invalid-species')
+    const promise = Promise.resolve()
+    const constructor = {}
+    Object.defineProperty(constructor, Symbol.species, {
+      get: () => { throw new Error('invalid species') },
+    })
+    void Object.defineProperty(promise, 'constructor', { value: constructor })
+
+    expect(service.withInitiator(initiator, () => promise)).toBe(promise)
+    await dispose()
+  })
+
   it('isolates overlapping initiators', async () => {
     const { service, dispose } = await harness()
     const a = agent('a')
@@ -160,4 +209,57 @@ describe('AgentRegistry initiator scope', () => {
     await disposal
     expect(disposed).toBe(true)
   })
+
+  it('does not self-deadlock when a boundary returns service disposal', async () => {
+    const { service, dispose } = await harness()
+    const initiator = agent('service-disposer')
+
+    const returned = service.withInitiator(initiator, dispose)
+    await promptly(returned)
+
+    expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+  })
+
+  it('does not self-deadlock when nested boundaries return ancestor disposal', async () => {
+    const { ctx, service } = await harness()
+    const parent = agent('parent-disposer')
+    const child = agent('child-disposer')
+    let disposal: Promise<void> | undefined
+
+    const returned = service.withInitiator(parent, () => service.withInitiator(child, () => {
+      disposal = ctx.fiber.dispose()
+      return disposal
+    }))
+    expect(returned).toBe(disposal)
+
+    await promptly(returned)
+    expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+  })
+
+  it('excludes an asynchronous teardown initiator while draining unrelated boundaries', async () => {
+    const { ctx, service } = await harness()
+    const initiator = agent('async-disposer')
+    const unrelated = agent('unrelated')
+    const release = Promise.withResolvers<boolean>()
+    const pending = service.withInitiator(unrelated, async () => {
+      await release.promise
+      expect(service.requireInitiator()).toBe(unrelated)
+    })
+
+    const returned = service.withInitiator(initiator, async () => {
+      await Promise.resolve()
+      await ctx.fiber.dispose()
+    })
+    let disposed = false
+    void returned.then(() => { disposed = true })
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+
+    release.resolve(true)
+    await pending
+    await promptly(returned)
+
+    expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+  })
 })

+ 17 - 17
website/zh-CN/api/harness/agents.md

@@ -6,7 +6,7 @@
 
 Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L204)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L211)
 
 ### ctx.agents.currentInitiator()
 
@@ -18,7 +18,7 @@ Read the Agent that initiated the inherited asynchronous driver chain.
 
 **Returns** the inherited Agent, or `undefined` outside a driver and inside an explicit clearing boundary.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L233)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L246)
 
 ### ctx.agents.requireInitiator()
 
@@ -30,7 +30,7 @@ Read the initiating Agent and fail when no driver boundary is active.
 
 **Returns** the inherited Agent.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L243)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L256)
 
 ### ctx.agents.withInitiator(agent, operation)
 
@@ -38,14 +38,14 @@ Read the initiating Agent and fail when no driver boundary is active.
 withInitiator<T>(agent: Agent, operation: () => T): T
 ```
 
-Run an operation with one exact Agent as its process-local initiator. The exact synchronous value or Promise returned by the operation is preserved.
+Run an operation with one exact Agent as its process-local initiator. The exact synchronous value or Promise returned by the operation is preserved. If its inherited async chain starts an owning-fiber unload, the nested boundary lineage is excluded from the drain so teardown cannot wait on itself.
 
 - `agent` — initiating Agent to inherit; presence is neither liveness proof nor authorization.
 - `operation` — synchronous or asynchronous operation to invoke.
 
 **Returns** the exact value returned by `operation`.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L257)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L272)
 
 ### ctx.agents.withoutInitiator(operation)
 
@@ -53,13 +53,13 @@ Run an operation with one exact Agent as its process-local initiator. The exact
 withoutInitiator<T>(operation: () => T): T
 ```
 
-Run an operation inside a boundary that hides any inherited initiating Agent. The exact synchronous value or Promise is preserved.
+Run an operation inside a boundary that hides any inherited initiating Agent. The exact synchronous value or Promise is preserved. If its inherited async chain starts an owning-fiber unload, the nested boundary lineage is excluded from the drain so teardown cannot wait on itself.
 
 - `operation` — synchronous or asynchronous operation to invoke without an initiator.
 
 **Returns** the exact value returned by `operation`.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L268)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L285)
 
 ### ctx.agents.setFactory(factory)
 
@@ -73,7 +73,7 @@ Register the agent-creation factory (the loop calls this on construction, effect
 
 **Returns** the disposer that clears the factory slot. The exact Cordis effect disposer (single-shot): composite (generator) effects may yield it directly — exact identity nests the teardown in order.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L284)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L301)
 
 ### ctx.agents.create(options)
 
@@ -87,7 +87,7 @@ Create and publish a new agent through the registered factory. Distinct from reg
 
 **Returns** the handle after setup, rollback-covered publication, and loop start complete.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L317)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L334)
 
 ### ctx.agents.resume(options)
 
@@ -101,7 +101,7 @@ Load a persisted session and resume an agent on it through the registered factor
 
 **Returns** the handle after setup, rollback-covered publication, and loop start complete.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L336)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L353)
 
 ### ctx.agents.register(agent)
 
@@ -115,7 +115,7 @@ Register a live agent. Throws if an agent with the same id is already registered
 
 **Returns** the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory's lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L362)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L379)
 
 ### ctx.agents.enter(agent, owner)
 
@@ -130,7 +130,7 @@ Insert an already-constructed agent without announcing it. This is the advanced
 
 **Returns** an idempotent closure that removes this exact entry and emits `agent/disposed` with listener failures contained. When called from a synchronous `agent/created` listener, removal and disposal wait until that creation dispatch unwinds.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L386)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L403)
 
 ### ctx.agents.announce(agent)
 
@@ -142,7 +142,7 @@ Announce an agent previously inserted with enter.
 
 - `agent` — the live inserted agent to announce.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L461)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L478)
 
 ### ctx.agents.get(id)
 
@@ -156,7 +156,7 @@ Look up a live agent.
 
 **Returns** the agent, or undefined when no live agent has that id.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L495)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L512)
 
 ### ctx.agents.isOwnedBy(id, owner)
 
@@ -171,7 +171,7 @@ Test whether a live agent was created through one exact parent agent's scoped co
 
 **Returns** true only while the exact child entry is live under that owner.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L507)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L524)
 
 ### ctx.agents.list()
 
@@ -183,7 +183,7 @@ All live agents, in registration order.
 
 **Returns** a fresh array; mutating it does not affect the registry.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L515)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L532)
 
 ### ctx.agents.roots()
 
@@ -195,4 +195,4 @@ All live top-level agents in registration order. A top-level agent was created w
 
 **Returns** a fresh array; mutating it does not affect the registry.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L525)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L542)