diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22d4565830..30ca04ba79 100644 --- a/docs/cordis-catalog/services.md +++ b/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(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` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index abea24e3a5..40048b2e7c 100644 --- a/docs/event-producer-consumer.md +++ b/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. diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 696b119797..9e2a50bd57 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/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 diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md index 08c9eca50e..b3c9be0be1 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/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. diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index 284d52126e..55494977b8 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/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 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 839907bf62..3cf8bbb6ad 100644 --- a/packages/core/agent/README.md +++ b/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) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index f2e774223d..24087011b7 100644 --- a/packages/core/agent/src/index.ts +++ b/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() private factory: FactorySlot | undefined private readonly initiators = new AsyncLocalStorage() + private readonly initiatorRuns = new AsyncLocalStorage() private initiatorState: 'active' | 'closing' | 'disposed' = 'active' private activeInitiatorRuns = 0 private initiatorDrain: PromiseWithResolvers | 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 { return (this.initiatorDisposal ??= (async () => { this.closeInitiators() + this.releaseReentrantInitiatorRuns() if (this.activeInitiatorRuns !== 0) { this.initiatorDrain ??= Promise.withResolvers() await this.initiatorDrain.promise } this.initiatorState = 'disposed' this.initiators.disable() + this.initiatorRuns.disable() })()) } /** Establish one tracked initiator or clearing boundary. */ private runWithInitiator(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() diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts index 87a8387e07..7e0b70d13c 100644 --- a/packages/core/agent/tests/agent-initiator.spec.ts +++ b/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(task: Promise): Promise { + const timeout = Promise.withResolvers() + 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() + 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((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 | 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() + 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') + }) }) diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index 328ea6097e..e2d94a84f4 100644 --- a/website/zh-CN/api/harness/agents.md +++ b/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(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(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)