fix(scope): harden lifecycle ownership foundation
Make Cordis construction and teardown ownership reentrancy-safe, then carry caller and provider ownership through reservation, setup, publication, quiescence, and sentinel retirement. Stabilize registry carriers and factory/workflow boundaries, add adversarial lifecycle regressions, and align the rewritten RFC plus generated contracts with the enforced behavior.
This commit is contained in:
@@ -8,7 +8,9 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. The capabilities reject competing `register`/`enter`/`prepare`/`create` calls, so setup cannot publish the factory objects or same-id replacements. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. The concrete agent owns runtime-pinned `id`, frozen detached `options`, `session`, and `ctx` bindings. Load/setup rejection or owner unload publishes nothing; partial creation announcements are paired during rollback. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, which contains sync/async listener failures per observer; per-step assembly goes through `assembleContextFor(agent)`; the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`.
|
||||
Lifecycle (scoped and dual-owned): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly; the trace-bound `AgentLoop` receiver still supplies the dependency origin, so a caller that injects only `agents` can create an agent whose scope reaches the loop's `sessions`/`llm`/`tools`/`systemPrompt` surface. The caller owns cancellation and the returned handle, while AgentLoop remains a structural second owner because the live driver depends on that service surface: unloading the provider aborts pending load/setup, tears down live programmatic agents, and awaits the same quiescence and ID-release boundary before its dependencies disappear.
|
||||
|
||||
The complete create transaction is factory-tracked before ID reservation/session validation, and both a factory placeholder and lifecycle-long caller sentinel exist before scope minting can reenter plugin lifecycle notifications. The caller sentinel adopts the exact reservation effects and always follows the memoized lifecycle boundary, including handle-first teardown followed by caller unload. Resume adds a load sentinel before persistence I/O; it waits for load settlement until `startOwned` synchronously returns a lifecycle/rollback disposer, then follows that disposer without a handoff gap. The ID capabilities reject competing `register`/`enter`/`prepare`/`create` calls and remain held through scope quiescence. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume captures each loaded metadata field once. After setup resolves, the factory checks caller and provider liveness after constructing both registry entries but before the first announcement, after `session/created`, after `agent/created`, and again after `agent/session-start` before starting the driver, so synchronous getter- or listener-triggered teardown wins. A publication-wide barrier flips lifecycle liveness immediately but keeps both entries and `agent.ctx` intact until the current synchronous notification phase unwinds; only then does rollback revoke them. Registry/store entries claim IDs across caller-code commit windows, detach exact objects only, and reuse stable carriers for paired edges. Load/setup rejection or owner unload before announcement emits no creation edge; if teardown begins inside a creation or session-start listener, the already-started notifications are paired during rollback and no live or drivable publication survives. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope → release reservations. After quiescence, the caller sentinel and any resume-load sentinel disarm and remove their owner-fiber effects so a long-lived caller does not retain the completed agent and scope. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`; the registry's paired disposal edge applies the same failure containment through its captured carrier. Per-step assembly goes through `assembleContextFor(agent)`, and the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
|
||||
@@ -17,7 +19,7 @@ Lifecycle (scoped): programmatic creation and resume snapshot caller-owned ident
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and detaches each raw value in one pass. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
|
||||
|
||||
### Injected services
|
||||
|
||||
|
||||
@@ -25,18 +25,23 @@ const claimedDriverSessions = new WeakSet<Session>()
|
||||
/** Module-private driver entry: its symbol is absent from the package surface. */
|
||||
const startDriver = Symbol('dsh.agent-loop.start-driver')
|
||||
|
||||
/** Module-private quiescent stop, valid both before and after driver start. */
|
||||
const stopDriver = Symbol('dsh.agent-loop.stop-driver')
|
||||
|
||||
/** Factory-owned controls that can operate only on the agent created with them. */
|
||||
export interface PreparedReactLoopAgent {
|
||||
/** The unpublished concrete agent. */
|
||||
agent: ReactLoopAgent
|
||||
/** Open its driving verbs at the rollback-covered publication boundary. */
|
||||
enableDrive(): void
|
||||
/** Stop the prepared instance even when publication has not started its loop. */
|
||||
dispose(): Promise<void> | void
|
||||
/**
|
||||
* Start its driver after publication and session-start notification.
|
||||
* The returned disposer reaches quiescence for both the loop and every
|
||||
* fire-and-forget idle-injection flush the agent started.
|
||||
*/
|
||||
startDriver(): () => Promise<void>
|
||||
startDriver(): () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,12 +61,20 @@ export function prepareReactLoopAgent(
|
||||
if (claimedDriverSessions.has(session)) {
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
}
|
||||
claimedDriverSessions.add(session)
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session)
|
||||
// Construction snapshots caller options and can throw. Claim only the fully
|
||||
// initialized driver so the same prepared session remains retryable after a
|
||||
// rejected caller value.
|
||||
claimedDriverSessions.add(session)
|
||||
const dispose = () => agent[stopDriver]()
|
||||
return {
|
||||
agent,
|
||||
enableDrive: () => { driveEnabledAgents.add(agent) },
|
||||
startDriver: () => agent[startDriver](),
|
||||
dispose,
|
||||
startDriver: () => {
|
||||
agent[startDriver]()
|
||||
return dispose
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +121,8 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
private currentAbort: AbortController | undefined
|
||||
/** Whether runLoop has been installed into {@link done}. */
|
||||
private driverStarted = false
|
||||
/**
|
||||
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
|
||||
* driver loop (via the LoopHandle) at every point a turn could start or
|
||||
@@ -361,17 +376,13 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the driver loop. Returns a disposer: calling it sets status to
|
||||
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
|
||||
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
|
||||
* aborts the current request if any. Its returned promise resolves only after
|
||||
* the loop exits and every idle-injection flush started by this agent settles.
|
||||
* @returns the disposer — idempotent, synchronously marks the agent disposed,
|
||||
* and asynchronously reaches loop + flush quiescence without rejecting (it
|
||||
* runs inside the fiber's LIFO disposal chain, where a rejection would skip
|
||||
* later disposers).
|
||||
* Start the driver loop. The prepared controller already owns its stable
|
||||
* disposer, so teardown can mark the agent disposed even in the narrow
|
||||
* publication window before this method runs.
|
||||
*/
|
||||
[startDriver](): () => Promise<void> {
|
||||
[startDriver](): void {
|
||||
if (this._status === 'disposed') return
|
||||
this.driverStarted = true
|
||||
this.done = runLoop(this.loopCtx, this, {
|
||||
inbox: this.#inbox,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
@@ -389,35 +400,50 @@ export class ReactLoopAgent implements Agent {
|
||||
// that would resolve a freshly-queued prompt as cancelled.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
})
|
||||
// The disposer must be infallible: it runs inside the fiber's LIFO
|
||||
// disposal chain, where a throw would skip later disposers (e.g. the
|
||||
// registry unregistration) and leave `done` pending forever.
|
||||
return async () => {
|
||||
if (this._status !== 'disposed') {
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
this.settleIdleWaiters()
|
||||
this.currentAbort?.abort('disposed')
|
||||
// setStatus refuses transitions out of 'disposed', so emit directly —
|
||||
// 'disposed' is part of the agent/status contract. Guarded: a throwing
|
||||
// listener must not break the disposal chain.
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiescent stop shared by pre-start rollback and live teardown. It marks the
|
||||
* agent disposed synchronously, contains an unexpected loop rejection, and
|
||||
* drains every idle-injection flush before resolving.
|
||||
*/
|
||||
private [stopDriver](): Promise<void> | void {
|
||||
if (this._status !== 'disposed') {
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
this.settleIdleWaiters()
|
||||
this.currentAbort?.abort('disposed')
|
||||
// An unpublished rollback has no public status lifecycle to announce.
|
||||
// Once driving is enabled, disposed is part of the agent/status contract.
|
||||
if (driveEnabledAgents.has(this)) {
|
||||
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
|
||||
}
|
||||
// An unexpected driver rejection must not skip registry/session/scope
|
||||
// cleanup. The normal loop contains turn failures itself; allSettled is the
|
||||
// final lifecycle backstop for anything outside those boundaries.
|
||||
await Promise.allSettled([this.done])
|
||||
// No new inject() can start after the synchronous disposed transition.
|
||||
// Loop because settled tasks retire themselves in promise reactions that
|
||||
// may run beside this continuation; either the set is empty or this waits
|
||||
// the exact remaining quiescence boundary. allSettled keeps a failure in
|
||||
// error reporting from skipping the registry/session/scope disposers.
|
||||
while (this.pendingIdleFlushes.size > 0) {
|
||||
await Promise.allSettled([...this.pendingIdleFlushes])
|
||||
}
|
||||
}
|
||||
// Before runLoop starts there is normally nothing asynchronous to drain;
|
||||
// keep publication rollback synchronous so create() cannot throw while its
|
||||
// session/agent entries are still briefly live. A session-start listener
|
||||
// may have used the newly enabled inject() surface, however, so preserve
|
||||
// its durability checkpoint as a real quiescence boundary.
|
||||
if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return
|
||||
return this.drainDriver()
|
||||
}
|
||||
|
||||
/** Await the loop (when started) and every outstanding idle flush. */
|
||||
private async drainDriver(): Promise<void> {
|
||||
// An unexpected driver rejection must not skip registry/session/scope
|
||||
// cleanup. The normal loop contains turn failures itself; allSettled is the
|
||||
// final lifecycle backstop for anything outside those boundaries.
|
||||
await Promise.allSettled([this.done])
|
||||
// No new inject() can start after the synchronous disposed transition.
|
||||
// Loop because settled tasks retire themselves in promise reactions that
|
||||
// may run beside this continuation; either the set is empty or this waits
|
||||
// the exact remaining quiescence boundary. allSettled keeps a failure in
|
||||
// error reporting from skipping registry/session/scope disposers.
|
||||
while (this.pendingIdleFlushes.size > 0) {
|
||||
await Promise.allSettled([...this.pendingIdleFlushes])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @module @deepseek-ai/dsh-agent-loop
|
||||
*/
|
||||
|
||||
import { Context, FiberState, Service } from 'cordis'
|
||||
import { Context, CordisError, FiberState, Service, symbols } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
@@ -31,6 +31,81 @@ interface RegistrationReservations {
|
||||
release(): void
|
||||
}
|
||||
|
||||
/** A synchronously established ownership handoff plus its async publication result. */
|
||||
interface OwnedAgentStart {
|
||||
result: Promise<AgentHandle>
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Internal carrier for a preparation error whose rollback still has to quiesce. */
|
||||
class LifecyclePreparationFailure extends Error {
|
||||
constructor(
|
||||
readonly reason: unknown,
|
||||
readonly dispose: () => Promise<void>,
|
||||
) {
|
||||
super('agent lifecycle preparation failed', { cause: reason })
|
||||
this.name = 'LifecyclePreparationFailure'
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable construction-time state shared by every traceable AgentLoop receiver. */
|
||||
interface FactoryOwnership {
|
||||
isActive(): boolean
|
||||
track(dispose: () => Promise<void>): () => void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Fiber states in which a concrete factory cannot safely serve dependencies. */
|
||||
const INACTIVE_FACTORY_STATES: ReadonlySet<FiberState> = new Set([
|
||||
FiberState.UNLOADING,
|
||||
FiberState.DISPOSED,
|
||||
FiberState.FAILED,
|
||||
])
|
||||
|
||||
/** Build a tamper-resistant controller around one factory's private ledger. */
|
||||
function createFactoryOwnership(fiber: Context['fiber']): FactoryOwnership {
|
||||
let accepting = true
|
||||
const transactions = new Set<() => Promise<void>>()
|
||||
const isActive = (): boolean => accepting && !INACTIVE_FACTORY_STATES.has(fiber.state)
|
||||
return Object.freeze({
|
||||
isActive,
|
||||
track(dispose: () => Promise<void>): () => void {
|
||||
/* v8 ignore next -- every call site checks the same controller immediately
|
||||
* before this synchronous, non-reentrant insertion; retain the guard as an invariant */
|
||||
if (!isActive()) throw new Error('agent loop is not active')
|
||||
transactions.add(dispose)
|
||||
return () => { transactions.delete(dispose) }
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
accepting = false
|
||||
const disposers = [...transactions]
|
||||
transactions.clear()
|
||||
const results = await Promise.allSettled(disposers.map(dispose => Promise.resolve().then(dispose)))
|
||||
/* v8 ignore next -- tracked lifecycle/load boundaries are deliberately
|
||||
* infallible; keep reasons if that lower-level contract ever breaks */
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
|
||||
/* v8 ignore next -- every tracked boundary is deliberately infallible;
|
||||
* preserve an exact unexpected single failure as a defensive backstop */
|
||||
if (errors.length === 1) throw errors[0]
|
||||
/* v8 ignore next -- multiple failures require multiple contract-breaking
|
||||
* lifecycle disposers, but teardown must still retain every cause */
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'agent loop transaction disposal failed')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Private ownership controllers keyed by the concrete, unproxied service. */
|
||||
const factoryOwnerships = new WeakMap<AgentLoop, FactoryOwnership>()
|
||||
|
||||
/** Recover the stable controller when a Cordis trace proxy is the receiver. */
|
||||
function factoryOwnershipFor(loop: AgentLoop): FactoryOwnership {
|
||||
const original = (loop as AgentLoop & { [symbols.original]?: AgentLoop })[symbols.original] ?? loop
|
||||
// Installed immediately after Service construction, before AgentLoop starts
|
||||
// any effect or config-driven transaction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return factoryOwnerships.get(original)!
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
@@ -94,6 +169,13 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentLoop')
|
||||
const factoryOwnership = createFactoryOwnership(ctx.fiber)
|
||||
factoryOwnerships.set(this, factoryOwnership)
|
||||
// Programmatic agents are caller-owned, but this implementation is their
|
||||
// dependency provider too. Retain a second ownership edge so unloading the
|
||||
// loop aborts unpublished work and drains every live lifecycle before its
|
||||
// service surface disappears.
|
||||
ctx.effect(() => () => factoryOwnership.dispose(), 'agentLoop.factoryTransactions()')
|
||||
// Provide the agent-creation factory to the registry (effect-scoped: the
|
||||
// slot is cleared on dispose).
|
||||
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
||||
@@ -118,7 +200,11 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// failed resume is contained + logged: startup must not crash.
|
||||
ctx.effect(() => {
|
||||
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
agentId: id,
|
||||
resumeSessionId,
|
||||
agentOptions: options,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
})
|
||||
@@ -135,6 +221,22 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this concrete factory may begin or publish more work. */
|
||||
private factoryIsActive(): boolean {
|
||||
return factoryOwnershipFor(this).isActive()
|
||||
}
|
||||
|
||||
/** Reject a call that raced the concrete loop's unload boundary. */
|
||||
private assertFactoryActive(): void {
|
||||
if (this.factoryIsActive()) return
|
||||
throw new Error('agent loop is not active')
|
||||
}
|
||||
|
||||
/** Add one memoized quiescence boundary to the factory's ownership set. */
|
||||
private trackFactoryTransaction(dispose: () => Promise<void>): () => void {
|
||||
return factoryOwnershipFor(this).track(dispose)
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven create: an agent on a FRESH, non-colliding session id per run
|
||||
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
|
||||
@@ -162,13 +264,17 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// lifecycle into the agent's composite effect (so a fiber unload tears the
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
let session: Session
|
||||
try {
|
||||
const session = reservations.session.prepare({ meta })
|
||||
const { agent } = this.start(id, options, session, 'startup', reservations)
|
||||
return agent
|
||||
} finally {
|
||||
session = reservations.session.prepare({ meta })
|
||||
} catch (error: unknown) {
|
||||
reservations.release()
|
||||
throw error
|
||||
}
|
||||
// start() accepts ownership of both reservation capabilities even when
|
||||
// synchronous preparation fails; its rollback releases them at quiescence.
|
||||
const { agent } = this.start(id, options, session, 'startup', reservations)
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,11 +286,13 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
|
||||
* starts with the parent's context. Returns an {@link AgentHandle} the owner
|
||||
* disposes to tear down exactly this agent.
|
||||
* @param ownerCtx - the caller context that owns setup and the live lifecycle.
|
||||
* @param options - agent id, caller-supplied session id, optional seed/meta,
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
this.assertFactoryActive()
|
||||
// Snapshot every caller-owned field before the first async setup boundary.
|
||||
// The callback itself is an identity capability. Agent options detach here;
|
||||
// seed and metadata stay raw only until sessions.prepare() synchronously
|
||||
@@ -196,16 +304,32 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const seed = options.seed
|
||||
const meta = options.meta
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
// Snapshot accessors can reenter plugin teardown; do not reserve identities
|
||||
// after the dependency provider has begun unloading.
|
||||
this.assertFactoryActive()
|
||||
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||
const disposeCreateForFactory = (): Promise<void> => transactionSettled
|
||||
const untrackFactoryCreate = this.trackFactoryTransaction(disposeCreateForFactory)
|
||||
try {
|
||||
const session = reservations.session.prepare({
|
||||
...seed !== undefined ? { seed } : {},
|
||||
...meta !== undefined ? { meta } : {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume.
|
||||
return await this.startOwned(agentId, agentOptions, session, 'startup', reservations, setup)
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
let lifecycleStarted = false
|
||||
try {
|
||||
const session = reservations.session.prepare({
|
||||
...seed !== undefined ? { seed } : {},
|
||||
...meta !== undefined ? { meta } : {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume.
|
||||
lifecycleStarted = true
|
||||
return await this.startOwned(ownerCtx, agentId, agentOptions, session, 'startup', reservations, setup).result
|
||||
} finally {
|
||||
// Once startOwned is invoked, even a preparation failure carries its
|
||||
// own quiescent rollback boundary. Only failures before that handoff
|
||||
// release directly here.
|
||||
if (!lifecycleStarted) reservations.release()
|
||||
}
|
||||
} finally {
|
||||
reservations.release()
|
||||
markTransactionSettled()
|
||||
untrackFactoryCreate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,10 +344,12 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* configured. NOT hard-injected (that would make non-persistent demos pend
|
||||
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
||||
* by the time this runs the service exists.
|
||||
* @param ownerCtx - the caller context that owns load, setup, and the live lifecycle.
|
||||
* @param options - the persisted session id to reload, plus agent id/options.
|
||||
* @returns the handle for the agent resumed on the reconstructed session.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
this.assertFactoryActive()
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
// global-store lookup keyed by the isolate symbol — NOT
|
||||
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
|
||||
@@ -243,7 +369,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
if (persistence === undefined) {
|
||||
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
return this.resumeWith(persistence, options)
|
||||
return this.resumeWith(ownerCtx, persistence, options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,7 +381,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* sessions store + registry are still read through `this.ctx` (both are in
|
||||
* AgentLoop's static inject, so they resolve fine).
|
||||
*/
|
||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
private async resumeWith(ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
// Persistence is an async trust boundary. Reserve, load, reconstruct, and
|
||||
// publish only the identities/options accepted at entry—never fields
|
||||
// reread from a caller-owned object after the await.
|
||||
@@ -263,25 +389,65 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const sessionId = options.resumeSessionId
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const setup = options.setup
|
||||
// Caller-owned accessors above are a synchronous reentrancy boundary: one
|
||||
// can begin factory unload while options are snapshotted. Re-check before
|
||||
// installing either ownership sentinel, so a rejected transaction leaves
|
||||
// no orphan effect or unresolved settlement promise.
|
||||
this.assertFactoryActive()
|
||||
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
|
||||
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||
let observingOwner = true
|
||||
// Resume must observe its caller from BEFORE persistence I/O begins. The
|
||||
// full agent lifecycle does not exist until load returns, so without this
|
||||
// sentinel a never-settling backend outlives owner disposal and holds both
|
||||
// public identities forever. `this.ctx.effect` retains the traceable caller
|
||||
// ownership used by startOwned's lifecycle effect. Install it before even
|
||||
// reserving the ids: an inactive owner cannot leak a reservation if effect
|
||||
// registration fails.
|
||||
const disposeLoadSentinel = this.ctx.effect(() => () => {
|
||||
if (!observingOwner) return
|
||||
// public identities forever. The caller-bound effect retains the same owner
|
||||
// later used by startOwned's lifecycle effect and adopts both reservation
|
||||
// disposers before persistence I/O begins.
|
||||
let lifecycleBoundary: (() => Promise<void>) | undefined
|
||||
let disposingForFactory: Promise<void> | undefined
|
||||
const disposeLoadForFactory = (): Promise<void> => (disposingForFactory ??= (async () => {
|
||||
markOwnerDisposed()
|
||||
// Owner-triggered teardown does not reach quiescence until the resume
|
||||
// transaction has observed disposal and released both reservations.
|
||||
return transactionSettled
|
||||
}, `agentLoop.resumeLoad(${agentId})`)
|
||||
await transactionSettled
|
||||
})())
|
||||
let untrackFactoryLoad: (() => void) | undefined
|
||||
let disposeLoadSentinel: (() => Promise<void> | void) | undefined
|
||||
let loadSentinelRetired = false
|
||||
const retireLoadSentinel = (): void => {
|
||||
/* v8 ignore next -- every lifecycle/rollback boundary is memoized and
|
||||
* invokes its after-quiescence hook once; retain idempotence defensively */
|
||||
if (loadSentinelRetired) return
|
||||
// Disarm the follower before invoking its wrapper: retirement can happen
|
||||
// from inside the lifecycle it used to follow, so recursing into that
|
||||
// same boundary here would deadlock final teardown.
|
||||
loadSentinelRetired = true
|
||||
observingOwner = false
|
||||
void disposeLoadSentinel?.()
|
||||
}
|
||||
let reservations: RegistrationReservations | undefined
|
||||
let lifecycleStarted = false
|
||||
try {
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
reservations = this.reserve(agentId, sessionId)
|
||||
const ownedReservations = reservations
|
||||
// Move both reservation effects under a sentinel BEFORE persistence I/O.
|
||||
// Its first teardown stage either aborts/waits for the load transaction
|
||||
// or follows the full lifecycle after handoff; only then do the exact
|
||||
// reservation disposers run. They therefore cannot race ahead as owner
|
||||
// siblings and reopen ids while load/setup/scope cleanup is still live.
|
||||
disposeLoadSentinel = ownerCtx.effect(function* () {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield ownedReservations.agent.release
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield ownedReservations.session.release
|
||||
yield () => {
|
||||
if (loadSentinelRetired) return
|
||||
if (observingOwner) {
|
||||
markOwnerDisposed()
|
||||
return transactionSettled
|
||||
}
|
||||
return lifecycleBoundary?.()
|
||||
}
|
||||
}, `agentLoop.resumeLoad(${agentId})`)
|
||||
untrackFactoryLoad = this.trackFactoryTransaction(disposeLoadForFactory)
|
||||
try {
|
||||
const loadTask = persistence.load(sessionId)
|
||||
const { meta, events } = await Promise.race([
|
||||
@@ -309,16 +475,26 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...seedLength !== undefined ? { seedLength } : {},
|
||||
},
|
||||
})
|
||||
// Calling startOwned synchronously installs the complete lifecycle
|
||||
// effect before it reaches its first setup await. Only then disarm the
|
||||
// load sentinel: ownership passes directly from one effect to the other
|
||||
// with no disposal gap.
|
||||
const starting = this.startOwned(agentId, agentOptions, session, 'resume', reservations, setup)
|
||||
// startOwned synchronously returns either the complete lifecycle or a
|
||||
// preparation-rollback boundary before its result reaches the first
|
||||
// setup await. Retarget the lifecycle-long load sentinel to that disposer;
|
||||
// ownership overlaps instead of creating a gap.
|
||||
lifecycleStarted = true
|
||||
const starting = this.startOwned(
|
||||
ownerCtx,
|
||||
agentId,
|
||||
agentOptions,
|
||||
session,
|
||||
'resume',
|
||||
reservations,
|
||||
setup,
|
||||
retireLoadSentinel,
|
||||
)
|
||||
lifecycleBoundary = starting.dispose
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
return await starting
|
||||
return await starting.result
|
||||
} finally {
|
||||
reservations.release()
|
||||
if (!lifecycleStarted) reservations.release()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
@@ -327,10 +503,18 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// owner already triggered cleanup, this idempotent second disposal is a
|
||||
// no-op and the owner's first cleanup remains parked on the shared
|
||||
// settlement promise.
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
if (!lifecycleStarted) {
|
||||
// Covers reserve succeeding but sentinel/factory tracking failing
|
||||
// before the inner load transaction begins.
|
||||
reservations?.release()
|
||||
// A failed pre-lifecycle transaction has already released directly;
|
||||
// retire the sentinel so it cannot remain as a stale owner effect.
|
||||
retireLoadSentinel()
|
||||
await disposeLoadSentinel?.()
|
||||
}
|
||||
} finally {
|
||||
markTransactionSettled()
|
||||
untrackFactoryLoad?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,17 +542,20 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
|
||||
/**
|
||||
* Construct an unpublished agent and synchronously install its complete
|
||||
* teardown skeleton before any setup await. The closures are assigned their
|
||||
* session/registry/loop disposers only at publication, while the exact scope
|
||||
* disposer is nested immediately. Therefore owner unload during setup flips
|
||||
* `active`, unwinds the scope, and wins the race without any late Cordis
|
||||
* effect collection.
|
||||
* teardown skeleton before any setup await. A lifecycle-long caller sentinel and
|
||||
* factory placeholder exist before driver/scope construction; the closures
|
||||
* receive their session/registry/loop disposers only at publication, while
|
||||
* the exact scope disposer is nested as soon as construction returns. Owner
|
||||
* unload during preparation or setup therefore follows a real rollback
|
||||
* boundary, flips liveness, and wins without late Cordis effect collection.
|
||||
*/
|
||||
private prepareLifecycle(
|
||||
ownerCtx: Context,
|
||||
id: AgentId,
|
||||
options: AgentOptions,
|
||||
session: Session,
|
||||
reservations: RegistrationReservations,
|
||||
afterQuiescence?: () => void,
|
||||
): {
|
||||
agent: ReactLoopAgent
|
||||
active: () => boolean
|
||||
@@ -381,77 +568,266 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// than Cordis reaches nested scope effects. Include that signal in the
|
||||
// pre-publication liveness check so a same-turn parent dispose cannot race
|
||||
// an already-fulfilled setup promise into briefly publishing a child.
|
||||
const ownerAgent = this.ctx.agent
|
||||
const ownerFiber = this.ctx.fiber
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
const { agent } = driver
|
||||
const scope: Scope = createScope(this.ctx, agent)
|
||||
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
|
||||
|
||||
let active = true
|
||||
let detachSession: (() => void) | undefined
|
||||
let detachAgent: (() => void) | undefined
|
||||
let stop: (() => Promise<void>) | undefined
|
||||
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
|
||||
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
|
||||
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
// First yielded, disposed last: every preceding teardown stage settled.
|
||||
yield () => { markTorndown() }
|
||||
// Exact identity moves the scope fiber out of the owner's concurrent
|
||||
// sibling list and into this ordered transaction.
|
||||
yield scope.rawDispose
|
||||
yield () => {
|
||||
detachSession?.()
|
||||
detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
detachAgent?.()
|
||||
detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first. Keep the pre-publication path
|
||||
// synchronous: returning a Promise only after the loop actually began
|
||||
// lets a failed announcement roll back registry/store before create's
|
||||
// rejection is observed.
|
||||
yield () => {
|
||||
active = false
|
||||
markDeactivated()
|
||||
if (stop === undefined) return
|
||||
return stop()
|
||||
}
|
||||
}, 'agentLoop.lifecycle()')
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
const disposeAgent = (): Promise<void> => (disposing ??= (async () => {
|
||||
await dispose()
|
||||
await torndown
|
||||
})())
|
||||
|
||||
const publish = (source: SessionStartSource): void => {
|
||||
// Publication is one synchronous, rollback-covered sequence. Setup has
|
||||
// already completed, so its scoped listeners observe both announcements.
|
||||
detachSession = agent.ctx.sessions.enter(session, reservations.session)
|
||||
detachAgent = this.ctx.agents.enter(agent, reservations.agent)
|
||||
this.ctx.sessions.announce(session)
|
||||
this.ctx.agents.announce(agent)
|
||||
// Setup is over and both entries are live. Open the driving surface just
|
||||
// before session-start so its listeners retain their supported ability to
|
||||
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||
driver.enableDrive()
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
stop = driver.startDriver()
|
||||
let ownerAgent: Context['agent']
|
||||
let ownerFiber: Context['fiber']
|
||||
try {
|
||||
this.assertFactoryActive()
|
||||
ownerCtx.fiber.assertActive()
|
||||
ownerAgent = ownerCtx.agent
|
||||
ownerFiber = ownerCtx.fiber
|
||||
} catch (error: unknown) {
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
const dispose = (): Promise<void> => Promise.resolve()
|
||||
throw new LifecyclePreparationFailure(error, dispose)
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
active: () => active
|
||||
// Establish BOTH ownership edges before driver preparation or scope
|
||||
// minting can publish an internal lifecycle notification. The lifecycle-long
|
||||
// caller sentinel also adopts the exact reservation effects: owner unload
|
||||
// first waits for the memoized lifecycle boundary, then reaches those
|
||||
// capabilities, so IDs cannot reopen while scope cleanup is still live.
|
||||
const { promise: lifecycleReady, resolve: markLifecycleReady }
|
||||
= Promise.withResolvers<() => Promise<void>>()
|
||||
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
|
||||
let ownerDisposed = false
|
||||
const ownerIsDisposed = (): boolean => ownerDisposed
|
||||
let ownerSentinelRetired = false
|
||||
let disposeOwnerSentinel: () => Promise<void> | void
|
||||
try {
|
||||
disposeOwnerSentinel = ownerCtx.effect(function* () {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield reservations.agent.release
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield reservations.session.release
|
||||
yield () => {
|
||||
if (ownerSentinelRetired) return
|
||||
ownerDisposed = true
|
||||
markDeactivated()
|
||||
return lifecycleReady.then(disposeLifecycle => disposeLifecycle())
|
||||
}
|
||||
}, `agentLoop.ownerLifecycle(${id})`)
|
||||
} catch (error: unknown) {
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
const dispose = (): Promise<void> => Promise.resolve()
|
||||
markLifecycleReady(dispose)
|
||||
// The only callback-free effect-install failure is Cordis's inactive
|
||||
// owner boundary; preserve the original value as cause for diagnostics.
|
||||
const reportedError = new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error })
|
||||
throw new LifecyclePreparationFailure(reportedError, dispose)
|
||||
}
|
||||
let disposingForFactory: Promise<void> | undefined
|
||||
const disposeForFactory = (): Promise<void> => (disposingForFactory ??= (async () => {
|
||||
const disposeLifecycle = await lifecycleReady
|
||||
await disposeLifecycle()
|
||||
})())
|
||||
let untrackFactory: () => void
|
||||
try {
|
||||
untrackFactory = this.trackFactoryTransaction(disposeForFactory)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore start -- no callback boundary exists between the active
|
||||
* factory check, sentinel installation, and this synchronous ledger insert */
|
||||
let cleanupTask: Promise<void> | undefined
|
||||
const cleanup = (): Promise<void> => (cleanupTask ??= Promise.resolve().then(() => {
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
}))
|
||||
markLifecycleReady(cleanup)
|
||||
void disposeOwnerSentinel()
|
||||
throw new LifecyclePreparationFailure(error, cleanup)
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
let scope: Scope | undefined
|
||||
let stopPrepared: (() => Promise<void> | void) | undefined
|
||||
let disposeAgent: (() => Promise<void>) | undefined
|
||||
try {
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
stopPrepared = () => driver.dispose()
|
||||
const { agent } = driver
|
||||
scope = createScope(this.ctx, agent)
|
||||
const lifecycleScope = scope
|
||||
if (ownerIsDisposed() || !this.factoryIsActive()
|
||||
|| ownerFiber.state === FiberState.UNLOADING
|
||||
|| ownerFiber.state === FiberState.DISPOSED
|
||||
|| ownerFiber.state === FiberState.FAILED
|
||||
|| ownerAgent?.status === 'disposed') {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
bindReactLoopAgentContext(agent, lifecycleScope.ctx.extend({ agent }))
|
||||
|
||||
let active = true
|
||||
let detachSession: (() => void) | undefined
|
||||
let detachAgent: (() => void) | undefined
|
||||
const stop = stopPrepared
|
||||
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
|
||||
const { promise: publicationSettled, resolve: markPublicationSettled } = Promise.withResolvers<void>()
|
||||
let publishing = false
|
||||
|
||||
const dispose = ownerCtx.effect(function* () {
|
||||
// First yielded, disposed last: every preceding teardown stage settled.
|
||||
yield () => {
|
||||
// Reservation ownership is part of lifecycle settlement: a factory
|
||||
// unload that awaited this disposer may reuse both ids immediately.
|
||||
reservations.release()
|
||||
// Retire both follower effects only after quiescence reached this final
|
||||
// stage. Their retired branches skip recursively disposing this same
|
||||
// lifecycle while their exact reservation children are already inert.
|
||||
ownerSentinelRetired = true
|
||||
void disposeOwnerSentinel()
|
||||
afterQuiescence?.()
|
||||
untrackFactory()
|
||||
markTorndown()
|
||||
}
|
||||
// Exact identity moves the scope fiber out of the owner's concurrent
|
||||
// sibling list and into this ordered transaction.
|
||||
yield lifecycleScope.rawDispose
|
||||
yield () => {
|
||||
detachSession?.()
|
||||
detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
detachAgent?.()
|
||||
detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first. Keep the pre-publication path
|
||||
// synchronous: returning a Promise only after the loop actually began
|
||||
// lets a failed announcement roll back registry/store before create's
|
||||
// rejection is observed.
|
||||
yield () => {
|
||||
active = false
|
||||
markDeactivated()
|
||||
// A listener can begin owner teardown reentrantly. Flip liveness now
|
||||
// so publish's next checkpoint aborts, but keep both registry entries
|
||||
// and the scope intact until the current synchronous publication
|
||||
// phase has unwound.
|
||||
if (publishing) return publicationSettled.then(stop)
|
||||
return stop()
|
||||
}
|
||||
}, 'agentLoop.lifecycle()')
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
disposeAgent = (): Promise<void> => (disposing ??= (async () => {
|
||||
await dispose()
|
||||
await torndown
|
||||
})())
|
||||
markLifecycleReady(disposeAgent)
|
||||
|
||||
const isActive = (): boolean => active
|
||||
&& !ownerIsDisposed()
|
||||
&& this.factoryIsActive()
|
||||
&& ownerFiber.state !== FiberState.UNLOADING
|
||||
&& ownerFiber.state !== FiberState.DISPOSED
|
||||
&& ownerFiber.state !== FiberState.FAILED
|
||||
&& ownerAgent?.status !== 'disposed',
|
||||
deactivated,
|
||||
publish,
|
||||
disposeAgent,
|
||||
&& ownerAgent?.status !== 'disposed'
|
||||
|
||||
const publish = (source: SessionStartSource): void => {
|
||||
publishing = true
|
||||
try {
|
||||
/* v8 ignore next 3 -- both callers check active immediately before
|
||||
* this callback-free synchronous publish entry */
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
// Publication is one synchronous, rollback-covered sequence. Setup has
|
||||
// already completed, so its scoped listeners observe both announcements.
|
||||
detachSession = agent.ctx.sessions.enter(session, reservations.session)
|
||||
detachAgent = this.ctx.agents.enter(agent, reservations.agent)
|
||||
// Both enter() calls capture stable dispatch carriers and therefore
|
||||
// evaluate a caller-owned Context.filter. A getter can begin teardown;
|
||||
// entries exist for rollback, but no creation edge may escape afterward.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
this.ctx.sessions.announce(session)
|
||||
// Session listeners can dispose an owner. Finish that dispatch while
|
||||
// both entries/scope remain live, then skip the agent edge entirely.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
this.ctx.agents.announce(agent)
|
||||
// Creation listeners may synchronously dispose either owner. Cordis
|
||||
// flips the relevant fiber state before it invokes nested effects, so
|
||||
// re-check here and never unlock a driver after teardown began.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
// Setup is over and both entries are live. Open the driving surface just
|
||||
// before session-start so its listeners retain their supported ability to
|
||||
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||
driver.enableDrive()
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
// session-start is the final synchronous listener boundary before the
|
||||
// loop begins. Teardown there must win just like teardown from either
|
||||
// creation announcement; the prebuilt driver disposer makes rollback
|
||||
// quiescent even though the loop never started.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
driver.startDriver()
|
||||
} finally {
|
||||
publishing = false
|
||||
markPublicationSettled()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
active: isActive,
|
||||
deactivated,
|
||||
publish,
|
||||
disposeAgent,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Preparation failed before startOwned received a lifecycle object. Give
|
||||
// a factory unload that already captured the placeholder a real boundary,
|
||||
// and retire the entry only after the minted scope (if any) is quiescent.
|
||||
const failedScope = scope
|
||||
const ownershipInactive = ownerIsDisposed() || ownerFiber.uid === null || !this.factoryIsActive()
|
||||
|| ownerFiber.state === FiberState.UNLOADING
|
||||
|| ownerFiber.state === FiberState.DISPOSED
|
||||
|| ownerFiber.state === FiberState.FAILED
|
||||
|| ownerAgent?.status === 'disposed'
|
||||
const reportedError = ownershipInactive && error instanceof CordisError
|
||||
? new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error })
|
||||
: error
|
||||
let fallbackTask: Promise<void> | undefined
|
||||
const cleanup = disposeAgent ?? (() => (fallbackTask ??= (async () => {
|
||||
try {
|
||||
await stopPrepared?.()
|
||||
} finally {
|
||||
try {
|
||||
await failedScope?.dispose()
|
||||
} finally {
|
||||
// Factory and caller quiescence include the prepared driver,
|
||||
// minted scope, and both unpublished identities even when the
|
||||
// complete lifecycle effect could not be installed.
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
}
|
||||
}
|
||||
})()))
|
||||
markLifecycleReady(cleanup)
|
||||
const cleanupTask = cleanup()
|
||||
// Retire the provisional owner edge. If owner unload already claimed it,
|
||||
// this is an inert repeat and that first caller is following cleanupTask.
|
||||
void disposeOwnerSentinel()
|
||||
void cleanupTask.then(
|
||||
untrackFactory,
|
||||
/* v8 ignore next -- Scope.dispose is specified to contain child
|
||||
* failures; preserve diagnostics if that lower-level contract breaks */
|
||||
(cleanupError: unknown) => {
|
||||
untrackFactory()
|
||||
try {
|
||||
this.ctx.logger.error(new AggregateError([reportedError, cleanupError], 'agent lifecycle preparation and rollback failed'))
|
||||
} catch {
|
||||
// Only a logger-export failure is swallowed: the original
|
||||
// preparation error is already propagating to the caller.
|
||||
}
|
||||
},
|
||||
)
|
||||
throw new LifecyclePreparationFailure(reportedError, cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +839,16 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
source: SessionStartSource,
|
||||
reservations: RegistrationReservations,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
|
||||
let lifecycle: ReturnType<AgentLoop['prepareLifecycle']>
|
||||
try {
|
||||
lifecycle = this.prepareLifecycle(this.ctx, id, options, session, reservations)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- prepareLifecycle converts every failure into its
|
||||
* rollback-bearing internal error before crossing this boundary */
|
||||
if (!(error instanceof LifecyclePreparationFailure)) throw error
|
||||
void error.dispose()
|
||||
throw error.reason
|
||||
}
|
||||
try {
|
||||
lifecycle.publish(source)
|
||||
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
|
||||
@@ -477,10 +862,10 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
|
||||
* handle's `dispose()` runs the composite effect's disposer (see
|
||||
* {@link start}) — which stops the loop, awaits its exit and outstanding
|
||||
* idle-injection flushes, unregisters the agent, and detaches the session, in
|
||||
* that order.
|
||||
* The same composite effect is what a fiber unload disposes, so both teardown
|
||||
* triggers honor the ordering identically.
|
||||
* idle-injection flushes, unregisters the agent, detaches the session,
|
||||
* unwinds the scope, and releases both ids, in that order. Caller-fiber unload
|
||||
* also invokes an independent sentinel that follows this memoized boundary,
|
||||
* so handle-first and owner-first races honor the same ordering.
|
||||
*
|
||||
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
|
||||
* single-shot (a second call returns immediately because the effect's epoch is
|
||||
@@ -491,13 +876,49 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
*/
|
||||
private async startOwned(
|
||||
private startOwned(
|
||||
ownerCtx: Context,
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
reservations: RegistrationReservations,
|
||||
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||
): Promise<AgentHandle> {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
|
||||
afterQuiescence?: () => void,
|
||||
): OwnedAgentStart {
|
||||
let lifecycle: ReturnType<AgentLoop['prepareLifecycle']>
|
||||
try {
|
||||
lifecycle = this.prepareLifecycle(ownerCtx, id, options, session, reservations, afterQuiescence)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 1 -- prepareLifecycle wraps every synchronous failure */
|
||||
if (!(error instanceof LifecyclePreparationFailure)) throw error
|
||||
return {
|
||||
dispose: error.dispose,
|
||||
result: (async () => {
|
||||
await error.dispose()
|
||||
throw error.reason
|
||||
})(),
|
||||
}
|
||||
}
|
||||
return {
|
||||
dispose: lifecycle.disposeAgent,
|
||||
result: this.finishOwnedStart(lifecycle, id, source, setup),
|
||||
}
|
||||
}
|
||||
|
||||
/** Await setup and publish after {@link startOwned} established ownership synchronously. */
|
||||
private async finishOwnedStart(
|
||||
lifecycle: ReturnType<AgentLoop['prepareLifecycle']>,
|
||||
id: AgentId,
|
||||
source: SessionStartSource,
|
||||
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||
): Promise<AgentHandle> {
|
||||
try {
|
||||
// Scope minting emits Cordis's synchronous internal/plugin notification.
|
||||
// A listener can unload either owner there; never run arbitrary setup in
|
||||
// the already-doomed scope while the tracked disposer is catching up.
|
||||
/* v8 ignore next 3 -- prepareLifecycle returns success only after its
|
||||
* final synchronous liveness check; no callback runs before this line */
|
||||
if (!lifecycle.active()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
// The owner-disposal branch makes a never-settling setup unable to hold
|
||||
// the transaction or its ID reservations forever. Promise.race installs
|
||||
// rejection observation on setup even if owner disposal wins first.
|
||||
|
||||
@@ -296,6 +296,39 @@ describe('ReactLoopAgent', () => {
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
|
||||
it('a pre-start disposal makes a later driver-start attempt inert', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
|
||||
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
const dispose = prepared.startDriver()
|
||||
await dispose()
|
||||
await expect(prepared.agent.done).resolves.toBeUndefined()
|
||||
expect(prepared.agent.session.events).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not claim a session when concrete-agent construction rejects options', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('constructor-retry'))
|
||||
const badOptions = {
|
||||
get model(): string {
|
||||
throw new Error('bad model getter')
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => prepareReactLoopAgent(ctx, AgentId('bad-constructor'), badOptions, session))
|
||||
.toThrow('bad model getter')
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('constructor-retry'), { model: 'mock' }, session)
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('setting the same status does not emit agent/status again', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -228,6 +228,27 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('successful resume disposal retires both caller ownership sentinels', async () => {
|
||||
const sessionId = SessionId('resume-retired-sentinels-s')
|
||||
const agentId = AgentId('resume-retired-sentinels')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const handle = await ctx.agents.resume({
|
||||
agentId,
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const sentinelLabels = [
|
||||
`agentLoop.resumeLoad(${agentId})`,
|
||||
`agentLoop.ownerLifecycle(${agentId})`,
|
||||
]
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(sentinelLabels))
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => sentinelLabels.includes(effect.label))).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
|
||||
const sessionId = SessionId('resume-setup-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
@@ -351,6 +372,93 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits reservation release', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const agentId = AgentId('resume-load-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
}
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
|
||||
await loadStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
|
||||
await promptly(loopFiber.dispose())
|
||||
await rejection
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(sessionId)
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('option snapshot reentrancy cannot install a resume sentinel after factory unload begins', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-factory-unload')
|
||||
const agentId = AgentId('resume-snapshot-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
let loads = 0
|
||||
const load = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
loads += 1
|
||||
return load(id)
|
||||
}
|
||||
const options = {
|
||||
agentId,
|
||||
resumeSessionId: sessionId,
|
||||
get agentOptions() {
|
||||
void loopFiber.dispose()
|
||||
return { model: 'mock' }
|
||||
},
|
||||
}
|
||||
|
||||
await expect(ctx.agents.resume(options)).rejects.toThrow('agent loop is not active')
|
||||
await loopFiber.dispose()
|
||||
expect(loads).toBe(0)
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.resumeLoad(${agentId})`)).toEqual([])
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(sessionId)
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('snapshots resume identities and agent options before persistence load', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-source')
|
||||
const root = await persistSession(sessionId)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -12,16 +12,20 @@ import * as concreteAgentModule from '../src/agent.ts'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) {
|
||||
async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
return { ctx, loopFiber }
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<Context> {
|
||||
return (await harnessWithLoop(adapter)).ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
@@ -37,6 +41,17 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
|
||||
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
|
||||
/** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */
|
||||
function disposeCurrentLifecycle(ownerCtx: Context): void {
|
||||
const lifecycle = [...ownerCtx.fiber._disposables]
|
||||
.find((dispose) => {
|
||||
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
|
||||
return effect?.label === 'agentLoop.lifecycle()'
|
||||
})
|
||||
if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
|
||||
void lifecycle()
|
||||
}
|
||||
|
||||
describe('agent scope lifecycle', () => {
|
||||
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
|
||||
const ctx = await harness()
|
||||
@@ -312,6 +327,508 @@ describe('agent scope lifecycle', () => {
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an AgentLoop unload aborts pending setup, awaits cleanup, and releases both ids', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-setup-race'),
|
||||
sessionId: SessionId('factory-setup-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
|
||||
await loopFiber.dispose()
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
||||
|
||||
// Factory unload itself reached the reservation-release boundary.
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-setup-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-setup-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
gate.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let unloaded = false
|
||||
let setupCalls = 0
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (unloaded || fiber.name !== 'scope') return
|
||||
unloaded = true
|
||||
void loopFiber.dispose()
|
||||
})
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-scope-race'),
|
||||
sessionId: SessionId('factory-scope-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => { setupCalls += 1 },
|
||||
})
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await loopFiber.dispose()
|
||||
expect(setupCalls).toBe(0)
|
||||
expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('caller unload during scope minting owns and drains the half-built child', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let ownerFiber!: Fiber
|
||||
let ownerDisposal!: Promise<void>
|
||||
let scopeFiber: Fiber | undefined
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'scope' || scopeFiber !== undefined) return
|
||||
scopeFiber = fiber
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
})
|
||||
ownerDisposal = ownerFiber.dispose()
|
||||
})
|
||||
|
||||
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerFiber = inner.fiber
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('caller-scope-race'),
|
||||
sessionId: SessionId('caller-scope-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
void ownerDisposal.then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
gate.resolve(undefined)
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await ownerDisposal
|
||||
await owner
|
||||
expect(scopeFiber?.uid).toBeNull()
|
||||
expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
|
||||
await owner.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('synchronous create rechecks provider liveness before its first publication edge', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
let unloaded = false
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (unloaded || fiber.name !== 'scope') return
|
||||
unloaded = true
|
||||
void loopFiber.dispose()
|
||||
})
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' }))
|
||||
.toThrow(/owner disposed during setup/)
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('synchronous create releases both reservations when session preparation fails', async () => {
|
||||
const ctx = await harness()
|
||||
const id = AgentId('config-prepare-failure')
|
||||
|
||||
expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' }))
|
||||
.toThrow(/absolute path/)
|
||||
const replacement = ctx.agentLoop.create(id, { model: 'mock' }, { cwd: '/recovered' })
|
||||
expect(ctx.agents.get(id)).toBe(replacement)
|
||||
await replacement.whenIdle()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('turns owner disposal from the caller association getter into a rollback boundary', async () => {
|
||||
const ctx = await harness()
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
let getterCalls = 0
|
||||
const creationStarted = Promise.withResolvers<undefined>()
|
||||
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
||||
Object.defineProperty(inner, 'agent', {
|
||||
configurable: true,
|
||||
get() {
|
||||
getterCalls += 1
|
||||
void inner.fiber.dispose()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('association-dispose'),
|
||||
sessionId: SessionId('association-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
creationStarted.resolve(undefined)
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await creationStarted.promise
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner
|
||||
expect(getterCalls).toBe(1)
|
||||
expect(ctx.agents.get(AgentId('association-dispose'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('association-dispose-s'))).toBeUndefined()
|
||||
const replacement = await ctx.agents.create({
|
||||
agentId: AgentId('association-dispose'),
|
||||
sessionId: SessionId('association-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await replacement.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload awaits reservations when reentrant scope preparation throws', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let triggered = false
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (triggered || fiber.name !== 'scope') return
|
||||
triggered = true
|
||||
void loopFiber.dispose()
|
||||
throw new Error('scope preparation failed')
|
||||
})
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('factory-scope-throw'),
|
||||
sessionId: SessionId('factory-scope-throw-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow('scope preparation failed')
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-throw'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-throw-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload during session preparation awaits create reservation release', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let unloading!: Promise<void>
|
||||
const meta = {
|
||||
get cwd() {
|
||||
unloading = loopFiber.dispose()
|
||||
return '/factory-unload'
|
||||
},
|
||||
}
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-prepare-race'),
|
||||
sessionId: SessionId('factory-prepare-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
meta,
|
||||
})
|
||||
await unloading
|
||||
await expect(creating).rejects.toThrow('agent loop is not active')
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-prepare-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-prepare-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
const loop = ctx.agentLoop
|
||||
const agentId = AgentId('factory-live')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('factory-live-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
await loopFiber.dispose()
|
||||
expect(handle.agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
|
||||
// The consumer handle shares the provider's completed quiescence boundary.
|
||||
await handle.dispose()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-live-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await expect(loop.createAgent(ctx, {
|
||||
agentId: AgentId('factory-inactive'),
|
||||
sessionId: SessionId('factory-inactive-s'),
|
||||
})).rejects.toThrow('agent loop is not active')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps AgentLoop dependencies available when the caller injects only agents', async () => {
|
||||
const ctx = await harness()
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('dependency-origin'),
|
||||
sessionId: SessionId('dependency-origin-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
agentCtx.tools.register({
|
||||
name: 'dependency-origin-tool',
|
||||
description: 'proves AgentLoop dependency origin',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve(text('ok')),
|
||||
})
|
||||
agentCtx.systemPrompt.section({
|
||||
name: 'dependency-origin-section',
|
||||
order: 1,
|
||||
text: 'factory dependency surface',
|
||||
})
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
const handle = await creating
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent))
|
||||
expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool')
|
||||
expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section')
|
||||
await handle.dispose()
|
||||
await owner.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps both entries and the scope live through a reentrant session/created teardown', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id !== SessionId('session-created-barrier-s')) return
|
||||
lifecycle.push('session-created:dispose')
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id !== SessionId('session-created-barrier-s')) return
|
||||
const agent = ctx.agents.get(AgentId('session-created-barrier'))!
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(agent.session).toBe(session)
|
||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||
lifecycle.push('session-created:observer')
|
||||
})
|
||||
ctx.on('agent/created', () => void lifecycle.push('agent-created'))
|
||||
ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed'))
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed')
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('session-created-barrier'),
|
||||
sessionId: SessionId('session-created-barrier-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created:dispose',
|
||||
'session-created:observer',
|
||||
'session-disposed',
|
||||
'scope-disposed',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id !== AgentId('agent-created-barrier')) return
|
||||
lifecycle.push('agent-created:dispose')
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id !== AgentId('agent-created-barrier')) return
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||
lifecycle.push('agent-created:observer')
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('agent-created-barrier'),
|
||||
sessionId: SessionId('agent-created-barrier-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created',
|
||||
'agent-created:dispose',
|
||||
'agent-created:observer',
|
||||
'agent-disposed',
|
||||
'session-disposed',
|
||||
'scope-disposed',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks owner liveness after carrier capture before the first creation edge', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
|
||||
const agentId = AgentId('carrier-owner-race')
|
||||
const sessionId = SessionId('carrier-owner-race-s')
|
||||
const lifecycle: string[] = []
|
||||
let filterReads = 0
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id === sessionId) lifecycle.push('session-created')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === sessionId) lifecycle.push('session-disposed')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id === agentId) lifecycle.push('agent-created')
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
if (agent.id === agentId) lifecycle.push('agent-disposed')
|
||||
})
|
||||
|
||||
const creating = ownerCtx.agents.create({
|
||||
agentId,
|
||||
sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
Object.defineProperty(agentCtx.agent!.session, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
filterReads += 1
|
||||
void owner.dispose()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(filterReads).toBe(1)
|
||||
expect(lifecycle).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
|
||||
const ctx = await harness()
|
||||
const starts: string[] = []
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
ctx.on('agent/session-start', agent => void starts.push(agent.id))
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('listener-dispose'),
|
||||
sessionId: SessionId('listener-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(starts).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks caller liveness after session-start before starting the driver', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
let announced!: ReactLoopAgent
|
||||
const statuses: string[] = []
|
||||
let scopeDisposed = false
|
||||
let observerSawLive = false
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
if (agent.id === AgentId('session-start-dispose')) statuses.push(status)
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
if (agent.id !== AgentId('session-start-dispose')) return
|
||||
announced = agent as ReactLoopAgent
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
if (agent.id !== AgentId('session-start-dispose')) return
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||
agent.ctx.effect(() => () => { scopeDisposed = true })
|
||||
observerSawLive = true
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('session-start-dispose'),
|
||||
sessionId: SessionId('session-start-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(announced.status).toBe('disposed')
|
||||
expect(statuses).toEqual(['disposed'])
|
||||
expect(observerSawLive).toBe(true)
|
||||
expect(scopeDisposed).toBe(true)
|
||||
expect(announced.session.events).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
|
||||
const ctx = await harness()
|
||||
const published: string[] = []
|
||||
@@ -527,6 +1044,89 @@ describe('agent scope lifecycle', () => {
|
||||
await unload
|
||||
})
|
||||
|
||||
it('successful handle disposal retires its caller ownership sentinel', async () => {
|
||||
const ctx = await harness()
|
||||
const agentId = AgentId('retired-owner-sentinel')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('retired-owner-sentinel-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.ownerLifecycle(${agentId})`)
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload after handle-first teardown follows the same in-flight boundary', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({
|
||||
agentId: AgentId('manual-first'),
|
||||
sessionId: SessionId('manual-first-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
agentCtx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
})
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
const disposing = handle.dispose()
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
const unloading = owner.dispose().then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([disposing, unloading])
|
||||
expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('retains both identity reservations until scope teardown reaches quiescence', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
const sessionDisposed = Promise.withResolvers<undefined>()
|
||||
const agentId = AgentId('quiescent-reservation')
|
||||
const sessionId = SessionId('quiescent-reservation-s')
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === sessionId) sessionDisposed.resolve(undefined)
|
||||
})
|
||||
const first = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
agentCtx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const disposing = first.dispose()
|
||||
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await expect(ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }))
|
||||
.rejects.toThrow(/reserved/)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await disposing
|
||||
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
|
||||
await replacement.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
|
||||
Reference in New Issue
Block a user