Merge remote-tracking branch 'origin/master' into codex/cordis-catalog-type-links
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# dsh-agent
|
||||
|
||||
Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
|
||||
Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
|
||||
|
||||
## Service: `AgentRegistry` (ctx key: `agents`)
|
||||
|
||||
Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package.
|
||||
Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package.
|
||||
|
||||
### Public API
|
||||
|
||||
@@ -17,6 +17,17 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
|
||||
- `ctx.agents.list(): Agent[]`
|
||||
- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root.
|
||||
|
||||
#### Initiating Agent scope
|
||||
|
||||
`AgentLoop` runs each concrete driver's complete lifetime inside an initiator boundary. Concurrent drivers remain isolated: a child driver's continuations carry the child, while the parent continuation regains the parent as soon as `withInitiator()` returns; drain tracking continues until the child driver's Promise settles. Creation, persistence load, and unpublished setup remain outside the child's boundary, so setup initiated by a parent inherits the parent while `agentCtx.agent` identifies the child explicitly.
|
||||
|
||||
- `ctx.agents.currentInitiator(): Agent | undefined` — read the inherited initiator without requiring one.
|
||||
- `ctx.agents.requireInitiator(): Agent` — read it or throw `no initiating agent is active`.
|
||||
- `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. 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)
|
||||
|
||||
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
|
||||
@@ -72,6 +83,8 @@ The handle every plugin programs against:
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts materialize any required identity explicitly.
|
||||
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
|
||||
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
|
||||
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent",
|
||||
"description": "Agent interface, registry, and event vocabulary for the DeepSeek Harness",
|
||||
"description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Agent registry service. Tracks live agents so plugins can find them without
|
||||
* depending on the concrete loop package. Agent creation belongs to the loop.
|
||||
* Agent service: live registry, factory delegation, and process-local
|
||||
* initiator scope. Concrete creation and driving belong to the loop.
|
||||
*
|
||||
* @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'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -173,6 +176,8 @@ export interface AgentFactory {
|
||||
|
||||
/** Thrown when create/resume is called before an agent factory is registered. */
|
||||
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
|
||||
const NO_INITIATOR_MESSAGE = 'no initiating agent is active'
|
||||
const DISPOSED_INITIATOR_MESSAGE = 'agent initiator scope is disposed'
|
||||
|
||||
/** All mutable lifecycle state for one exact registry entry. */
|
||||
interface AgentEntry {
|
||||
@@ -186,21 +191,38 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
|
||||
* orchestrator plugins can find them without depending on the concrete loop
|
||||
* package. Agent *creation* is provided by whichever plugin implements the
|
||||
* {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link setFactory}.
|
||||
* 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 {@link AgentFactory}
|
||||
* (`@deepseek-ai/dsh-agent-loop`), registered via {@link setFactory}.
|
||||
*
|
||||
* Initiator methods provide same-process causal attribution only. Ambient
|
||||
* presence is neither liveness proof nor authorization; subjects and owners
|
||||
* remain explicit, as does identity at worker, process, persistence, and wire
|
||||
* boundaries. Returned Promise boundaries drain during teardown, except a
|
||||
* nested lineage that starts an owning-fiber unload is excluded from its own drain.
|
||||
*/
|
||||
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
|
||||
private initiatorDisposal: Promise<void> | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
@@ -211,6 +233,75 @@ 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() }
|
||||
}.bind(this), 'agents.initiatorLifecycle()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent that initiated the inherited asynchronous driver chain.
|
||||
* Use this optional form for logging, tracing, metrics, or host attribution
|
||||
* that also supports agentless calls. When a parent creates a child, setup
|
||||
* reports the causal parent while `agentCtx.agent` identifies the child.
|
||||
* @returns the inherited Agent, or `undefined` outside an initiator boundary
|
||||
* and inside an explicit clearing boundary.
|
||||
* @throws when this service instance has been disposed.
|
||||
*/
|
||||
currentInitiator(): Agent | undefined {
|
||||
this.assertInitiatorsReadable()
|
||||
return this.initiators.getStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the initiating Agent and fail when no initiator boundary is active.
|
||||
* Use this for private helpers contractually below a driver, or for a
|
||||
* deployment-owned outbound request whose contract forbids agentless calls.
|
||||
* Generic or direct-call seams use optional lookup or explicit request fields.
|
||||
* @returns the inherited Agent.
|
||||
* @throws when no initiator is active or this service instance has been disposed.
|
||||
*/
|
||||
requireInitiator(): Agent {
|
||||
const agent = this.currentInitiator()
|
||||
if (agent === undefined) throw new Error(NO_INITIATOR_MESSAGE)
|
||||
return 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.
|
||||
* Custom drivers and test harnesses wrap their complete returned foreground
|
||||
* lifetime.
|
||||
* A queue or wire receiver may establish this boundary only after validating
|
||||
* explicit identity and resolving the exact live Agent; this method does neither.
|
||||
* Detached work remains owned by the subsystem that starts it.
|
||||
* @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`.
|
||||
* @throws when the initiator scope is closing/disposed, or when `operation` throws.
|
||||
*/
|
||||
withInitiator<T>(agent: Agent, operation: () => T): T {
|
||||
return this.runWithInitiator(agent, operation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an operation inside a boundary that hides any inherited initiating
|
||||
* Agent. The exact synchronous value or Promise is preserved.
|
||||
* Use this while creating lazy shared timers, queue pumps, pool maintenance,
|
||||
* watchers, or exporters so they do not inherit the first Agent that happens
|
||||
* to initialize them. It clears only initiator attribution, not explicit
|
||||
* fields, and does not own or drain detached resources.
|
||||
* @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.
|
||||
*/
|
||||
withoutInitiator<T>(operation: () => T): T {
|
||||
return this.runWithInitiator(undefined, operation)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,6 +562,92 @@ export class AgentRegistry extends Service {
|
||||
.filter(entry => entry.owner === undefined)
|
||||
.map(entry => entry.agent)
|
||||
}
|
||||
|
||||
/** Reject new initiator boundaries while inherited continuations drain. */
|
||||
private closeInitiators(): void {
|
||||
if (this.initiatorState === 'active') this.initiatorState = 'closing'
|
||||
}
|
||||
|
||||
/** Wait for returned-Promise boundaries, then invalidate retained references. */
|
||||
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.initiatorRuns.run(run, () => this.initiators.run(agent, operation))
|
||||
} catch (error: unknown) {
|
||||
this.releaseInitiatorRun(run)
|
||||
throw error
|
||||
}
|
||||
if (isPromise(result)) {
|
||||
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(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)
|
||||
}
|
||||
|
||||
/** 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()
|
||||
this.initiatorDrain = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentRegistry
|
||||
|
||||
265
packages/core/agent/tests/agent-initiator.spec.ts
Normal file
265
packages/core/agent/tests/agent-initiator.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
function agent(id: string): Agent {
|
||||
return { id: SessionId(id) } as Agent
|
||||
}
|
||||
|
||||
async function harness(): Promise<{
|
||||
ctx: Context
|
||||
service: AgentRegistry
|
||||
dispose: () => Promise<void>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
service: ctx.agents,
|
||||
dispose: fiber.dispose,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('preserves exact synchronous and Promise return identities across await', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('identity')
|
||||
const value = { result: true }
|
||||
expect(service.withInitiator(initiator, () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
return value
|
||||
})).toBe(value)
|
||||
|
||||
const promise = service.withInitiator(initiator, async () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
await Promise.resolve()
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
return value
|
||||
})
|
||||
expect(service.withInitiator(initiator, () => promise)).toBe(promise)
|
||||
await expect(promise).resolves.toBe(value)
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
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')
|
||||
const b = agent('b')
|
||||
const bothStarted = Promise.withResolvers<boolean>()
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
let starts = 0
|
||||
const run = (initiator: Agent): Promise<void> => service.withInitiator(initiator, async () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
starts += 1
|
||||
if (starts === 2) bothStarted.resolve(true)
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
})
|
||||
|
||||
const pending = [run(a), run(b)]
|
||||
await bothStarted.promise
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
release.resolve(true)
|
||||
await Promise.all(pending)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('restores nested and explicitly cleared boundaries', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const parent = agent('parent')
|
||||
const child = agent('child')
|
||||
|
||||
service.withInitiator(parent, () => {
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
service.withInitiator(child, () => { expect(service.requireInitiator()).toBe(child) })
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
service.withoutInitiator(() => {
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
|
||||
})
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
})
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('restores the parent after synchronous throws and rejected operations', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const parent = agent('parent')
|
||||
const child = agent('child')
|
||||
const syncError = new Error('sync failure')
|
||||
const asyncError = new Error('async failure')
|
||||
|
||||
service.withInitiator(parent, () => {
|
||||
expect(() => service.withInitiator(child, () => { throw syncError })).toThrow(syncError)
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
})
|
||||
await expect(service.withInitiator(child, async () => {
|
||||
await Promise.resolve()
|
||||
throw asyncError
|
||||
})).rejects.toBe(asyncError)
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
|
||||
const { ctx, service, dispose } = await harness()
|
||||
const initiator = agent('draining')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const pending = service.withInitiator(initiator, async () => {
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(() => service.withInitiator(initiator, () => 1)).toThrow('agent initiator scope is disposed')
|
||||
expect(() => service.withoutInitiator(() => 1)).toThrow('agent initiator scope is disposed')
|
||||
expect(disposed).toBe(false)
|
||||
expect(ctx.get('agents')).toBeUndefined()
|
||||
release.resolve(true)
|
||||
await pending
|
||||
await disposal
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
expect(() => service.requireInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
|
||||
it('drains cross-realm Promise boundaries before disposal', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('cross-realm')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const operation = runInNewContext(
|
||||
'(async () => { await release; inspect() })',
|
||||
{
|
||||
release: release.promise,
|
||||
inspect: () => { expect(service.requireInitiator()).toBe(initiator) },
|
||||
},
|
||||
) as () => Promise<void>
|
||||
const pending = service.withInitiator(initiator, operation)
|
||||
expect(pending).not.toBeInstanceOf(Promise)
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
release.resolve(true)
|
||||
await pending
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user