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:
Tianyi Cui
2026-07-12 08:57:05 +08:00
parent 197f7237d2
commit c5b1a7941f
39 changed files with 2945 additions and 461 deletions

View File

@@ -57,8 +57,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
methods: [
'create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
'async createAgent(options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
],
},
{
@@ -251,7 +251,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/disposed',
mode: 'emit',
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
summary: 'An agent was removed from the registry after its driver and any in-flight turn reached quiescence.',
summary: 'An agent was removed from the registry.',
},
{
name: 'agent/error',
@@ -497,7 +497,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AgentFactory',
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise<AgentHandle>;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
},
{
name: 'AgentHandle',

View File

@@ -0,0 +1,287 @@
import { Context, CordisError, FiberState, type Fiber } from 'cordis'
import { describe, expect, it } from 'vitest'
/**
* Direct regressions for the vendored Cordis ownership substrate used by
* tool-cordis's dynamic plugin tree and every other harness plugin.
*/
describe('Cordis effect ownership', () => {
it('makes an effect visible to a reentrant owner restart and awaits setup plus cleanup', async () => {
const ctx = new Context()
const setupGate = Promise.withResolvers<undefined>()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let restarted!: Promise<void>
let setupFinished = false
let cleanupFinished = false
ctx.effect(async () => {
restarted = ctx.fiber.restart()
await setupGate.promise
setupFinished = true
return async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
cleanupFinished = true
}
}, 'reentrant-restart')
let settled = false
void restarted.then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
setupGate.resolve(undefined)
await cleanupStarted.promise
expect(setupFinished).toBe(true)
await Promise.resolve()
expect(settled).toBe(false)
cleanupGate.resolve(undefined)
await restarted
expect(cleanupFinished).toBe(true)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('rolls back collected cleanup and its owner-list entry when setup throws synchronously', () => {
const ctx = new Context()
let cleanups = 0
expect(() => ctx.effect(function* () {
yield () => { cleanups += 1 }
throw new Error('setup failed')
}, 'throwing-setup')).toThrow('setup failed')
expect(cleanups).toBe(1)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('makes a reentrant owner restart await asynchronous rollback after synchronous setup failure', async () => {
const ctx = new Context()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let restarted!: Promise<void>
expect(() => ctx.effect(function* () {
yield async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
}
restarted = ctx.fiber.restart()
throw new Error('setup failed after restart')
}, 'reentrant-throw')).toThrow('setup failed after restart')
await cleanupStarted.promise
let settled = false
void restarted.then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
cleanupGate.resolve(undefined)
await restarted
expect(ctx.fiber.getEffects()).toEqual([])
})
it('keeps ordinary teardown synchronous and the public disposer single-shot', () => {
const ctx = new Context()
let cleanups = 0
const dispose = ctx.effect(() => () => { cleanups += 1 }, 'sync-effect')
expect(dispose()).toBeUndefined()
expect(cleanups).toBe(1)
expect(dispose()).toBeUndefined()
expect(cleanups).toBe(1)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('rejects cleanup-time registration while a restart is unloading', async () => {
const ctx = new Context()
let registrationError: unknown
ctx.effect(() => () => {
try {
ctx.effect(() => () => {}, 'too-late')
} catch (error) {
registrationError = error
}
}, 'restart-cleanup')
await ctx.fiber.restart()
expect(registrationError).toBeInstanceOf(CordisError)
expect((registrationError as CordisError).code).toBe('INACTIVE_EFFECT')
expect(ctx.fiber.state).toBe(FiberState.ACTIVE)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('keeps effect registration legal while child fibers are PENDING and LOADING', async () => {
const ctx = new Context()
let pendingCleanup = false
let loadingCleanup = false
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'state-probe' || fiber.uid === null) return
expect(fiber.state).toBe(FiberState.PENDING)
fiber.ctx.effect(() => () => { pendingCleanup = true }, 'pending-effect')
})
const fiber = await ctx.plugin({
name: 'state-probe',
apply(inner) {
expect(inner.fiber.state).toBe(FiberState.LOADING)
inner.effect(() => () => { loadingCleanup = true }, 'loading-effect')
},
})
await fiber.dispose()
expect(pendingCleanup).toBe(true)
expect(loadingCleanup).toBe(true)
})
it('resolves dependencies that internal/plugin adds before child activation', async () => {
const ctx = new Context()
ctx.provide('late-inject', {})
let applyCalls = 0
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'loader-shaped' || fiber.uid === null) return
fiber.inject['late-inject'] = {}
})
const fiber = await ctx.plugin({
name: 'loader-shaped',
apply() {
applyCalls += 1
},
})
expect(applyCalls).toBe(1)
expect(fiber.state).toBe(FiberState.ACTIVE)
})
})
describe('Cordis child publication ownership', () => {
it('rolls back parent and runtime ownership when internal/plugin publication throws', () => {
const ctx = new Context()
const plugin = { name: 'publication-failure', apply() {} }
ctx.on('internal/plugin', (fiber) => {
if (fiber.name === plugin.name) throw new Error('publication failed')
})
expect(() => ctx.plugin(plugin)).toThrow('publication failed')
expect(ctx.registry.has(plugin)).toBe(false)
})
it('contains teardown notification failures so ownership cleanup and peers complete', async () => {
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const observed: string[] = []
ctx.on('internal/plugin', (fiber) => {
if (fiber.name === 'contained-teardown' && fiber.uid === null) {
throw new Error('broken teardown observer')
}
})
ctx.on('internal/plugin', (fiber) => {
if (fiber.name === 'contained-teardown' && fiber.uid === null) observed.push('disposed')
})
const child = await ctx.plugin({ name: 'contained-teardown', apply() {} })
await expect(child.dispose()).resolves.toBeUndefined()
expect(observed).toEqual(['disposed'])
expect(errors).toHaveLength(1)
expect(errors[0]).toEqual(expect.objectContaining({ message: 'broken teardown observer' }))
expect(child.uid).toBeNull()
})
it('makes a LOADING parent join child cleanup started before its unload snapshot', async () => {
const ctx = new Context()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let ownerFiber!: Fiber
let ownerDisposal!: Promise<void>
let childDisposal!: Promise<void>
let childFiber!: Fiber
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'loading-child' || fiber.uid === null) return
childFiber = fiber
fiber.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
}, 'loading-child-cleanup')
ownerDisposal = ownerFiber.dispose()
childDisposal = Promise.resolve(fiber.dispose())
})
const ownerMount = ctx.plugin({
name: 'loading-owner',
apply(inner) {
ownerFiber = inner.fiber
inner.plugin({ name: 'loading-child', apply() {} })
},
})
await cleanupStarted.promise
let ownerSettled = false
void ownerDisposal.then(() => { ownerSettled = true })
await Promise.resolve()
expect(ownerSettled).toBe(false)
cleanupGate.resolve(undefined)
await Promise.all([ownerDisposal, childDisposal, ownerMount])
expect(childFiber.uid).toBeNull()
expect(ownerFiber.uid).toBeNull()
})
it('lets parent disposal during internal/plugin await the unpublished child to quiescence', async () => {
const ctx = new Context()
let ownerCtx!: Context
const owner = await ctx.plugin({
name: 'owner',
apply(inner) {
ownerCtx = inner
},
})
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let cleanupFinished = false
let childApplyCalls = 0
let parentDisposal!: Promise<void>
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'child' || fiber.uid === null) return
expect(fiber.state).toBe(FiberState.PENDING)
fiber.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
cleanupFinished = true
}, 'pending-child-cleanup')
})
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'child' || fiber.uid === null) return
parentDisposal = owner.dispose()
})
const child = ownerCtx.plugin({
name: 'child',
apply() {
childApplyCalls += 1
},
})
await cleanupStarted.promise
let settled = false
void parentDisposal.then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
cleanupGate.resolve(undefined)
await parentDisposal
expect(cleanupFinished).toBe(true)
expect(childApplyCalls).toBe(0)
expect(child.uid).toBeNull()
expect(child.state).toBe(FiberState.DISPOSED)
})
})

View File

@@ -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

View File

@@ -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])
}
}
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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({

View File

@@ -8,28 +8,28 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair deliberately reuses the stable carrier captured before entry commit and applies the same per-listener containment directly. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary.
- `ctx.agents.register(agent: Agent): () => Promise<void> | void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability owned by the calling fiber (owner unload releases an abandoned reservation); `enter(agent, reservation?): () => void` inserts under one captured, runtime-pinned id without announcing; and `announce(agent)` emits `agent/created` exactly once for that exact live entry, rejecting repeat or reentrant announcement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`.
- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability whose `release` is the exact owner effect disposer, allowing the factory to place ID release after scope quiescence instead of racing owner unload as a sibling. `enter(agent, reservation?): () => void` claims the ID across runtime pinning and stable lifecycle-carrier construction, then inserts without announcing; a Proxy trap or filter getter cannot reentrantly overwrite the commit. `announce(agent)` reuses that carrier and emits `agent/created` exactly once for the exact live entry, rejecting repeat or reentrant announcement. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach is exact-object guarded, so a later listener cannot observe inverted lifecycle edges and a stale capability cannot delete a replacement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`.
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
#### Factory seam (creation)
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package.
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target, captures and validates the factory's `createAgent` and `resume` callbacks once at registration, retains that target as their intentional receiver, and passes each call an explicit caller-bound `ownerCtx`; later method replacement cannot redirect a transaction, double tracing cannot break raw-identity state, and a plain non-Cordis factory receives enough context to implement caller ownership.
- `ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert both session and agent, then recheck caller and factory liveness before the first creation announcement and after each later notification boundary. Only a still-live transaction opens `agent/session-start` and starts a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, caller unload, factory unload, or cancellation from a creation listener publishes no drivable agent. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert both → pre-announcement liveness check → session announcement → liveness check → agent announcement → liveness check → session-start → final liveness check → loop-start boundary. The IDs are reserved across persistence load, setup, and teardown quiescence; load/setup rejection, caller unload, or factory unload leaves no drivable or live publication, while any creation edge that already began is paired during rollback. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
### Live events
`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events.
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` runs after the driver is quiescent and the agent leaves the registry, while ordered teardown may still be detaching its session and unwinding its scope.
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#owner-final-policy-boundaries).

View File

@@ -1,12 +1,13 @@
/**
* Fused scope-carrier dispatch for agent-subject events, plus the assembly
* context builder. The ONE sanctioned spelling for dispatching `agent/*`
* events: `agentEvents(ctx, agent).waterfall('agent/request', …)` builds the
* scope carrier ({@link scopeTarget} keyed by the agent) AND injects the
* subject as the first event argument in one move, so the correct dispatch is
* also the shortest — a dispatch site cannot pass a carrier keyed to one
* agent while naming another as the subject, which is the invariant the
* dev-mode scoped-dispatch check asserts at runtime.
* Fused scope-carrier dispatch for agent-subject operations, plus the assembly
* context builder. The sanctioned ordinary spelling is
* `agentEvents(ctx, agent).waterfall('agent/request', …)`: it builds the scope
* carrier ({@link scopeTarget} keyed by the agent) AND injects the subject as
* the first argument in one move, so a site cannot name a different subject.
* The registry lifecycle pair is the deliberate exception: `enter()` captures
* one stable carrier before commit and `announce()`/detach dispatch through it
* directly, preventing a mutable filter getter from changing or reentering the
* paired edges. The dev scoped-dispatch invariant checks both shapes.
*
* @module @deepseek-ai/dsh-agent/dispatch
*/

View File

@@ -5,11 +5,11 @@
* @module @deepseek-ai/dsh-agent
*/
import { Context, Service } from 'cordis'
import { Context, getTraceable, Service, symbols } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentId, AgentOptions } from './types.ts'
import { agentEvents } from './dispatch.ts'
export * from './types.ts'
export { agentEvents, assembleContextFor } from './dispatch.ts'
@@ -112,17 +112,21 @@ export interface ResumeAgentOptions {
/**
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
* can tear this agent down. `dispose()` stops the loop, awaits its exit and
* every outstanding idle-injection flush (quiescence — NOT just the `disposed`
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers,
* only the holder can tear this agent down. The registered factory provider is
* also a structural owner because the scoped agent depends on that provider's
* service surface; provider unload stops and drains every live handle it made.
* `dispose()` stops the loop, awaits its exit and every outstanding
* idle-injection flush (quiescence — NOT just the `disposed`
* status flip), unregisters the agent, removes its session from the store, and
* finally unwinds its scoped world. This order captures every agent-started
* `session/flush` before the session is detached and keeps scoped listeners
* alive through those checkpoints.
*
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
* for the OWNER that created it. Config-created agents (the loop's own startup)
* are owned by the loop fiber and never need a handle.
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is
* exposed only to the consumer owner that created it; the structural provider
* reaches the same teardown internally. Config-created agents (the loop's own
* startup) are owned by the loop fiber and never need a handle.
*/
export interface AgentHandle {
agent: Agent
@@ -146,20 +150,62 @@ export interface AgentFactory {
* that began is paired by `agent/disposed` or `session/disposed` during
* rollback. The owner disposes the resolved handle to stop/drain,
* unregister, remove the session, and unwind the scope.
* The registry passes a context carrying the `create()` caller's fiber and
* scope as `ownerCtx`. The implementation attaches the unpublished
* transaction and resulting lifecycle to that owner; it must not infer
* ownership from the factory object's registration context.
* @param ownerCtx - caller-bound context that owns the transaction and live handle.
* @param options - agent/session identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
*/
createAgent(options: CreateAgentOptions): Promise<AgentHandle>
createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
/**
* Load a persisted session and resume an agent on it. Async because it awaits
* both `ctx.sessionPersistence.load` and the optional unpublished setup
* transaction; must be called after that service exists (consumers inject
* `sessionPersistence`). Publication and drive unlocking follow the same
* ordered boundary as {@link createAgent}.
* @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
* @param options - persisted identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
*/
resume(options: ResumeAgentOptions): Promise<AgentHandle>
resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
}
/** One accepted factory target plus the callback identities captured at registration. */
interface AcceptedAgentFactory {
target: AgentFactory
createAgent: AgentFactory['createAgent']
resume: AgentFactory['resume']
}
/** Slot reservation while callback accessors are being captured. */
const ACCEPTING_FACTORY = Symbol('accepting agent factory')
/** Capture and validate the complete factory contract exactly once. */
function acceptAgentFactory(factory: unknown): AcceptedAgentFactory {
if ((typeof factory !== 'object' && typeof factory !== 'function') || factory === null) {
throw new TypeError('agent factory must be a non-null object or function')
}
// A service read through ctx is already a Cordis trace proxy. Retaining that
// proxy and tracing it again for each create() caller produces two shadow
// layers; raw-identity state (AgentLoop's private ownership controller is
// one example) then unwraps only to the inner proxy instead of its service.
// Canonicalize the one framework-produced layer at acceptance and capture
// callbacks from the concrete target. Plain objects expose no original.
const original: unknown = Reflect.get(factory, symbols.original)
const target = ((typeof original === 'object' || typeof original === 'function') && original !== null)
? original
: factory
const createAgent: unknown = Reflect.get(target, 'createAgent')
const resume: unknown = Reflect.get(target, 'resume')
if (typeof createAgent !== 'function') throw new TypeError('agent factory createAgent must be a function')
if (typeof resume !== 'function') throw new TypeError('agent factory resume must be a function')
return Object.freeze({
target: target as AgentFactory,
createAgent: createAgent as AgentFactory['createAgent'],
resume: resume as AgentFactory['resume'],
})
}
/** Thrown when create/resume is called before an agent factory is registered. */
@@ -187,6 +233,8 @@ export interface AgentRegistrationReservation {
/**
* Release the unpublished reservation; idempotent. The registry also
* releases it automatically when the fiber that called `reserve` disposes.
* This function is that exact Cordis effect disposer, so an ordered
* lifecycle may yield it by identity and place release after quiescence.
* @returns nothing.
*/
release(): void
@@ -201,13 +249,21 @@ export interface AgentRegistrationReservation {
*/
export class AgentRegistry extends Service {
private store = new Map<AgentId, Agent>()
/** Ids claimed across caller-code boundaries before their exact entry commits. */
private enteringIds = new Set<AgentId>()
/** The one accepted registry key for each live agent; never reread caller state. */
private acceptedIds = new WeakMap<Agent, AgentId>()
/** Unpublished identities held across factory setup/load transactions. */
private reservations = new Map<AgentId, AgentRegistrationReservation>()
/** Entries whose `agent/created` announcement phase began. */
private announced = new WeakSet<Agent>()
private factory: AgentFactory | undefined
/** Entries currently dispatching `agent/created`; detach waits for that dispatch to unwind. */
private announcing = new WeakSet<Agent>()
/** A detach requested reentrantly from `agent/created`. */
private pendingDetach = new WeakSet<Agent>()
/** Stable lifecycle dispatch carrier captured before an entry commits. */
private carriers = new WeakMap<Agent, Scoped<Agent>>()
private factory: AcceptedAgentFactory | typeof ACCEPTING_FACTORY | undefined
constructor(ctx: Context) {
super(ctx, 'agents')
@@ -234,39 +290,27 @@ export class AgentRegistry extends Service {
*/
reserve(id: AgentId): AgentRegistrationReservation {
if (typeof id !== 'string') throw new TypeError('agent id must be a string')
if (this.store.has(id) || this.reservations.has(id)) {
if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) {
throw new Error(`agent "${id}" is already registered or reserved`)
}
let active = true
const rawRelease = (): void => {
if (!active) return
active = false
this.reservations.delete(id)
}
let disposeEffect!: () => Promise<void> | void
const reservation: AgentRegistrationReservation = Object.freeze({
id,
release: () => {
rawRelease()
// Remove the now-inert ownership effect on manual transaction settle;
// its cleanup is the exact idempotent raw release above.
void disposeEffect()
},
})
// `release` is the exact effect disposer. A composite lifecycle can yield
// it by identity, moving automatic owner cleanup from a racing sibling to
// the transaction's final ordered position.
const release = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`)
const reservation: AgentRegistrationReservation = Object.freeze({ id, release })
this.reservations.set(id, reservation)
try {
disposeEffect = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`)
} catch (error: unknown) {
rawRelease()
throw error
}
return reservation
}
/**
* Register the agent-creation factory (the loop calls this on construction,
* effect-scoped). Throws if a factory is already registered. Returns the
* disposer; on dispose the factory slot is cleared.
* effect-scoped). The registry captures both callback identities once and
* later invokes them against the retained target receiver. Throws if a
* factory is already registered. Returns the disposer; on dispose the
* factory slot is cleared.
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
* @returns the disposer that clears the factory slot. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -275,7 +319,17 @@ export class AgentRegistry extends Service {
setFactory(factory: AgentFactory): () => Promise<void> | void {
const dispose = this.ctx.effect(() => {
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
this.factory = factory
// Claim the slot before reading caller-controlled method accessors. A
// getter may synchronously re-enter setFactory(); it must observe the
// registration in progress instead of installing a nested factory that
// the outer call would silently overwrite.
this.factory = ACCEPTING_FACTORY
try {
this.factory = acceptAgentFactory(factory)
} catch (error: unknown) {
this.factory = undefined
throw error
}
return () => { this.factory = undefined }
}, 'agents.setFactory()')
// The exact cordis effect disposer (the agents.register() convention): a
@@ -285,6 +339,13 @@ export class AgentRegistry extends Service {
return dispose
}
/** Return the accepted factory, excluding absence and reentrant acceptance. */
private requireFactory(): AcceptedAgentFactory {
const accepted = this.factory
if (accepted === undefined || accepted === ACCEPTING_FACTORY) throw new Error(NO_FACTORY_MESSAGE)
return accepted
}
/**
* Create and publish a new agent through the registered factory.
* Distinct from {@link register} (which records an already-constructed
@@ -295,8 +356,14 @@ export class AgentRegistry extends Service {
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async create(options: CreateAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.createAgent(options)
const accepted = this.requireFactory()
const ownerCtx = this.ctx
// Re-trace a Service-backed factory through the accessing context
// explicitly. This preserves AgentLoop's dependency origin while binding
// its effects to ownerCtx; plain factories receive ownerCtx as an explicit
// capability and need no Cordis tracker magic.
const receiver = getTraceable(ownerCtx, accepted.target)
return Reflect.apply(accepted.createAgent, receiver, [ownerCtx, options])
}
/**
@@ -307,8 +374,10 @@ export class AgentRegistry extends Service {
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.resume(options)
const accepted = this.requireFactory()
const ownerCtx = this.ctx
const receiver = getTraceable(ownerCtx, accepted.target)
return Reflect.apply(accepted.resume, receiver, [ownerCtx, options])
}
/**
@@ -347,7 +416,9 @@ export class AgentRegistry extends Service {
* @param reservation - the exact unpublished-id capability, when a factory
* reserved this id across setup.
* @returns an idempotent closure that removes this exact entry and emits
* `agent/disposed` with listener failures contained.
* `agent/disposed` with listener failures contained. When called from a
* synchronous `agent/created` listener, removal and disposal wait until
* that creation dispatch unwinds.
*/
enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void {
const id = agent.id
@@ -361,39 +432,108 @@ export class AgentRegistry extends Service {
if (this.acceptedIds.has(agent)) {
throw new Error(`agent "${id}" is already registered`)
}
if (this.store.has(id)) {
if (this.store.has(id) || this.enteringIds.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
this.enteringIds.add(id)
let carrier: Scoped<Agent>
try {
// Registration accepts ownership of the public identity contract. Pin an
// own data slot from the one captured value so a custom JavaScript Agent
// with a getter or writable field cannot later present a different id to
// event listeners while the registry still owns the accepted key.
Object.defineProperty(agent, 'id', {
value: id,
enumerable: true,
writable: false,
configurable: false,
})
} catch {
// Only the engine's property-definition failure is swallowed; the stable
// public error below is the registration contract exposed to callers.
throw new TypeError('agent id must be installable as a stable own property')
try {
Object.defineProperty(agent, 'id', {
value: id,
enumerable: true,
writable: false,
configurable: false,
})
} catch {
// Only the engine's property-definition failure is normalized; filter
// construction below retains its own precise failure.
throw new TypeError('agent id must be installable as a stable own property')
}
// Capture one carrier for the paired lifecycle edges. Constructing it
// reads a custom Agent's Context.filter and is therefore caller code;
// the id claim above makes a same-id reentrant enter lose deterministically.
carrier = scopeTarget(agent, agent)
} finally {
// Kept through the entire caller-code window; the final commit below is
// synchronous and callback-free.
this.enteringIds.delete(id)
}
const currentReservation = this.reservations.get(id)
if (reservation === undefined) {
/* v8 ignore next 2 -- reserve() rejects enteringIds, so no callback in
* carrier construction can install a new same-id reservation */
if (currentReservation !== undefined) {
throw new Error(`agent "${id}" is reserved for unpublished creation`)
}
} else if (currentReservation !== reservation) {
throw new Error(`agent "${id}" registration reservation is not active for this id`)
}
/* v8 ignore next 2 -- the enteringIds claim blocks every public same-id
* commit until this callback-free final check has completed */
if (this.acceptedIds.has(agent) || this.store.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
this.store.set(id, agent)
this.acceptedIds.set(agent, id)
this.carriers.set(agent, carrier)
let entered = true
return () => {
const detach = (): void => {
if (!entered) return
entered = false
this.store.delete(id)
this.acceptedIds.delete(agent)
// An insertion rolled back before announce was never externally created,
// so emitting disposed would invent an impossible lifecycle edge. Marking
// happens before the created emit: if a later created listener throws,
// earlier listeners may already have observed it and must see disposal.
if (!this.announced.delete(agent)) return
agentEvents(this.ctx, agent).emit('agent/disposed')
// Every callback reached by this creation dispatch must observe the same
// live entry, and disposal must follow creation. A listener may own
// the advanced detach capability, so make that ordering structural:
// visibility and the paired disposal are deferred until announce()'s
// synchronous dispatch has unwound.
if (this.announcing.has(agent)) {
this.pendingDetach.add(agent)
return
}
this.detachEntered(agent, id)
}
return detach
}
/** Remove one exact entered agent and emit its paired disposal when announced. */
private detachEntered(agent: Agent, id: AgentId): void {
this.pendingDetach.delete(agent)
// A stale capability can never delete a later same-id lifecycle. The
// commit claim prevents this mismatch in normal operation; retain the
// exact-object guard as the final identity boundary.
/* v8 ignore next 1 -- the commit claim makes replacement impossible; this
* remains the exact-identity backstop against future mutation paths */
if (this.store.get(id) !== agent || this.acceptedIds.get(agent) !== id) return
this.store.delete(id)
this.acceptedIds.delete(agent)
const carrier = this.carriers.get(agent)
this.carriers.delete(agent)
// An insertion rolled back before announce was never externally created,
// so emitting disposed would invent an impossible lifecycle edge. Marking
// happens before the created emit: if a later created listener throws,
// earlier listeners may already have observed it and must see disposal.
if (!this.announced.delete(agent)) return
/* v8 ignore next -- enter commits the carrier with the exact store entry */
if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`)
this.emitDisposed(agent, carrier, id)
}
/** Emit the paired disposal edge through the entry's stable carrier. */
private emitDisposed(agent: Agent, carrier: Scoped<Agent>, id: AgentId): void {
const args: unknown[] = [carrier, 'agent/disposed', agent]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${id}": agent/disposed listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/disposed listener threw: ${renderThrown(error)}`)
}
}
}
@@ -409,21 +549,30 @@ export class AgentRegistry extends Service {
if (id === undefined || this.store.get(id) !== agent) {
throw new Error(`agent "${id ?? '<unknown>'}" is not live in this registry`)
}
if (this.announced.has(agent)) {
if (this.announced.has(agent) || this.announcing.has(agent)) {
throw new Error(`agent "${id}" was already announced`)
}
const carrier = this.carriers.get(agent)
/* v8 ignore next -- enter commits the carrier with the exact store entry */
if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`)
// Mark before dispatch so a listener cannot recursively create a second
// lifecycle edge; detach still pairs a partially delivered first edge.
this.announcing.add(agent)
this.announced.add(agent)
const args: unknown[] = [scopeTarget(agent, agent), 'agent/created', agent]
for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back.
// Returned-promise rejection happens after this synchronous boundary, so
// observe and report it instead of leaking an unhandled rejection.
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`)
})
const args: unknown[] = [carrier, 'agent/created', agent]
try {
for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back.
// Returned-promise rejection happens after this synchronous boundary, so
// observe and report it instead of leaking an unhandled rejection.
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`)
})
}
} finally {
this.announcing.delete(agent)
if (this.pendingDetach.has(agent)) this.detachEntered(agent, id)
}
}

View File

@@ -291,7 +291,11 @@ declare module 'cordis' {
* to inject or queue work during startup. A synchronous listener throw
* vetoes publication and rollback emits the matching disposal edges;
* returned-promise rejection is observed and logged but cannot
* retroactively veto this synchronous boundary.
* retroactively veto this synchronous boundary. A synchronous listener
* that requests the advanced registry detach does not remove the entry
* immediately: removal and the paired `agent/disposed` edge wait until the
* creation dispatch unwinds, so no later creation listener observes a
* disposal that preceded its own creation callback.
* @param agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
@@ -302,11 +306,12 @@ declare module 'cordis' {
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
/**
* An agent was removed from the registry after its driver and any in-flight
* turn reached quiescence. Ordered teardown may still be detaching the
* session and unwinding the agent's scoped registrations when this
* notification runs.
* @param agent - the deregistered agent; its driving handle is now inert.
* An agent was removed from the registry. The concrete AgentLoop lifecycle
* emits this only after its driver and any in-flight turn reach quiescence;
* a custom agent registered through the public registry owns its own driver
* contract, which the registry cannot infer. Ordered teardown may still be
* detaching the session and unwinding scoped registrations when this runs.
* @param agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
@@ -348,11 +353,12 @@ declare module 'cordis' {
/**
* The agent's session lifecycle began, fired once before its first turn.
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): it
* carries no veto — a session-start listener that wants to seed context does
* so via `agent.inject()` (a `context/message` the first request sees), not
* by returning a decision. Cannot block the session from starting; that gap
* is deliberate (a bridge logs/injects, it does not gate startup).
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): a
* listener cannot veto by returning a decision or throwing. A listener that
* wants to seed context does so via `agent.inject()` (a `context/message` the
* first request sees). A lifecycle owner can still dispose its structural
* ownership edge during this notification; publication rechecks liveness and
* then aborts before the driver starts.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context, Service, symbols } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Agent, AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
const id = AgentId(rawId)
@@ -178,6 +179,96 @@ describe('AgentRegistry', () => {
expect(() => ctx.agents.enter(pinnedAccessor)).toThrow(/installable as a stable own property/)
})
it('claims an id across a Proxy defineProperty trap before committing the exact entry', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const id = AgentId('reentrant-enter')
const nested = stubAgent(id)
let nestedError = ''
let attempted = false
const target = stubAgent(id)
const outer = new Proxy(target, {
defineProperty(inner, property, descriptor) {
if (property === 'id' && !attempted) {
attempted = true
try {
ctx.agents.enter(nested)
} catch (error: unknown) {
nestedError = String(error)
}
}
return Reflect.defineProperty(inner, property, descriptor)
},
})
const detach = ctx.agents.enter(outer)
expect(nestedError).toMatch(/already registered/)
expect(ctx.agents.get(id)).toBe(outer)
detach()
expect(ctx.agents.get(id)).toBeUndefined()
})
it('captures one lifecycle carrier before commit so a filter getter cannot invert edges', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const events: string[] = []
const agent = stubAgent('reentrant-carrier')
let detach = (): void => {}
Object.defineProperty(agent, Context.filter, {
configurable: true,
get() {
events.push('filter-getter')
detach()
return undefined
},
})
detach = ctx.agents.enter(agent)
ctx.on('agent/created', () => { events.push('created') })
ctx.on('agent/disposed', () => { events.push('disposed') })
ctx.agents.announce(agent)
expect(events).toEqual(['filter-getter', 'created'])
expect(ctx.agents.get(agent.id)).toBe(agent)
detach()
expect(events).toEqual(['filter-getter', 'created', 'disposed'])
expect(ctx.agents.get(agent.id)).toBeUndefined()
})
it('revalidates an exact reservation after carrier construction runs caller code', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const reservation = ctx.agents.reserve(AgentId('released-during-enter'))
const agent = stubAgent('released-during-enter')
Object.defineProperty(agent, Context.filter, {
configurable: true,
get() {
reservation.release()
return undefined
},
})
expect(() => ctx.agents.enter(agent, reservation)).toThrow(/reservation is not active/)
expect(ctx.agents.get(agent.id)).toBeUndefined()
})
it('observes an async agent/disposed rejection through the stable carrier', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
ctx.on('agent/disposed', () => Promise.reject(new Error('late disposal failure')) as never)
const agent = stubAgent('async-disposed')
const detach = ctx.agents.enter(agent)
ctx.agents.announce(agent)
detach()
await Promise.resolve()
await Promise.resolve()
expect(warnings).toEqual([
'agent "async-disposed": agent/disposed listener rejected: Error: late disposal failure',
])
})
it('uses an opaque one-id reservation to gate unpublished factory insertion', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
@@ -251,6 +342,34 @@ describe('AgentRegistry', () => {
detach()
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
})
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const order: string[] = []
const agent = stubAgent('reentrant-detach')
const detach = ctx.agents.enter(agent)
ctx.on('agent/created', (created) => {
order.push('created:first')
detach()
expect(ctx.agents.get(created.id)).toBe(created)
})
ctx.on('agent/created', (created) => {
order.push('created:second')
expect(ctx.agents.get(created.id)).toBe(created)
})
ctx.on('agent/disposed', (disposed) => {
order.push('disposed')
expect(ctx.agents.get(disposed.id)).toBeUndefined()
})
ctx.agents.announce(agent)
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
expect(ctx.agents.get(agent.id)).toBeUndefined()
detach()
})
})
describe('agentEvents()', () => {
@@ -281,14 +400,17 @@ describe('agentEvents()', () => {
describe('AgentRegistry factory seam', () => {
/** A stub AgentFactory that records calls and returns a stub agent. */
function stubFactory() {
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
async createAgent(options) {
calls.create.push(options)
const calls: {
create: Array<{ ownerCtx: Context; options: CreateAgentOptions }>
resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }>
} = { create: [], resume: [] }
const factory: AgentFactory = {
async createAgent(ownerCtx, options) {
calls.create.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
resume(options) {
calls.resume.push(options)
resume(ownerCtx, options) {
calls.resume.push({ ownerCtx, options })
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
},
}
@@ -310,11 +432,151 @@ describe('AgentRegistry factory seam', () => {
const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
expect(created.agent.id).toBe('c1')
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
expect(calls.create).toHaveLength(1)
expect(calls.create[0]!.ownerCtx.fiber).toBe(ctx.fiber)
expect(calls.create[0]!.options)
.toEqual({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
expect(resumed.agent.id).toBe('r1')
expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
expect(calls.resume).toHaveLength(1)
expect(calls.resume[0]!.ownerCtx.fiber).toBe(ctx.fiber)
expect(calls.resume[0]!.options).toEqual({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
})
it('passes the calling fiber to a plain factory for create and resume ownership', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
let callerFiber: Context['fiber'] | undefined
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
callerFiber = inner.fiber
await inner.agents.create({ agentId: AgentId('owned-create'), sessionId: SessionId('owned-session') })
await inner.agents.resume({ agentId: AgentId('owned-resume'), resumeSessionId: SessionId('persisted') })
}, { inject: ['agents'] }))
expect(calls.create[0]!.ownerCtx.fiber).toBe(callerFiber)
expect(calls.resume[0]!.ownerCtx.fiber).toBe(callerFiber)
await owner.dispose()
})
it('captures factory callbacks once while retaining the intentional target receiver', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const reads = { create: 0, resume: 0 }
const receivers: unknown[] = []
const replacements: string[] = []
const target = { label: 'accepted-target' } as { label: string } & AgentFactory
Object.defineProperties(target, {
createAgent: {
configurable: true,
get() {
reads.create += 1
return function (this: typeof target, _ownerCtx: Context, options: CreateAgentOptions) {
receivers.push(this)
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
}
},
},
resume: {
configurable: true,
get() {
reads.resume += 1
return function (this: typeof target, _ownerCtx: Context, options: ResumeAgentOptions) {
receivers.push(this)
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
}
},
},
})
ctx.agents.setFactory(target)
Object.defineProperties(target, {
createAgent: {
value: () => {
replacements.push('create')
return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() })
},
},
resume: {
value: () => {
replacements.push('resume')
return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() })
},
},
})
await ctx.agents.create({ agentId: AgentId('captured-create'), sessionId: SessionId('captured-session') })
await ctx.agents.resume({ agentId: AgentId('captured-resume'), resumeSessionId: SessionId('captured-persisted') })
expect(reads).toEqual({ create: 1, resume: 1 })
expect(receivers).toEqual([target, target])
expect(replacements).toEqual([])
})
it('reserves the factory slot before reading reentrant callback accessors', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const nested = stubFactory().factory
const reads: string[] = []
const reentrantCreate: Promise<unknown>[] = []
const target = {} as AgentFactory
Object.defineProperties(target, {
createAgent: {
get() {
reads.push('createAgent')
expect(() => ctx.agents.setFactory(nested)).toThrow(/already registered/)
reentrantCreate.push(ctx.agents.create({
agentId: AgentId('during-acceptance'),
sessionId: SessionId('during-acceptance-session'),
}))
return (_ownerCtx: Context, options: CreateAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
})
},
},
resume: {
get() {
reads.push('resume')
return (_ownerCtx: Context, options: ResumeAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
})
},
},
})
ctx.agents.setFactory(target)
expect(reentrantCreate).toHaveLength(1)
await expect(Promise.all(reentrantCreate)).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.create({
agentId: AgentId('after-acceptance'),
sessionId: SessionId('after-acceptance-session'),
})).resolves.toMatchObject({ agent: { id: 'after-acceptance' } })
expect(reads).toEqual(['createAgent', 'resume'])
})
it('validates the complete factory shape when accepting it', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
expect(() => ctx.agents.setFactory(null as unknown as AgentFactory)).toThrow(/non-null object or function/)
expect(() => ctx.agents.setFactory(42 as unknown as AgentFactory)).toThrow(/non-null object or function/)
expect(() => ctx.agents.setFactory({ resume() { return Promise.resolve() } } as unknown as AgentFactory))
.toThrow(/createAgent must be a function/)
expect(() => ctx.agents.setFactory({ createAgent() { return Promise.resolve() } } as unknown as AgentFactory))
.toThrow(/resume must be a function/)
const callable = Object.assign(() => undefined, stubFactory().factory)
const dispose = ctx.agents.setFactory(callable)
await expect(ctx.agents.create({ agentId: AgentId('callable'), sessionId: SessionId('callable-session') }))
.resolves.toBeDefined()
await dispose()
})
it('setFactory rejects a second factory', async () => {
@@ -337,4 +599,81 @@ describe('AgentRegistry factory seam', () => {
// factory slot cleared → create throws again
await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/)
})
it('canonicalizes an already traced Service factory before caller retracing', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const states = new WeakMap<object, string[]>()
class TracedFactory extends Service implements AgentFactory {
constructor(inner: Context) {
super(inner, 'tracedFactory')
states.set(this, [])
}
private calls(): string[] {
const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this
const calls = states.get(original)
if (calls === undefined) throw new Error('factory receiver did not canonicalize to the raw service')
return calls
}
createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
this.calls().push('create')
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
}
resume(_ownerCtx: Context, options: ResumeAgentOptions) {
this.calls().push('resume')
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
}
}
await ctx.plugin(TracedFactory)
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
ctx.agents.setFactory(traced)
await ctx.agents.create({ agentId: AgentId('traced-create'), sessionId: SessionId('traced-session') })
await ctx.agents.resume({ agentId: AgentId('traced-resume'), resumeSessionId: SessionId('traced-persisted') })
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
expect(states.get(raw!)).toEqual(['create', 'resume'])
})
it('rolls back register and factory acceptance when their owner unloads reentrantly', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let ownerCtx!: Context
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
const agent = stubAgent('register-unload-race')
ctx.on('agent/created', (created) => {
if (created === agent) void owner.dispose()
})
ownerCtx.agents.register(agent)
await owner.dispose()
expect(ctx.agents.get(agent.id)).toBeUndefined()
let factoryOwnerCtx!: Context
const factoryOwner = await ctx.plugin(Object.assign((inner: Context) => { factoryOwnerCtx = inner }, { inject: ['agents'] }))
const target = {} as AgentFactory
Object.defineProperties(target, {
createAgent: {
get() {
void factoryOwner.dispose()
return (_inner: Context, options: CreateAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
})
},
},
resume: {
value: (_inner: Context, options: ResumeAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
}),
},
})
factoryOwnerCtx.agents.setFactory(target)
await factoryOwner.dispose()
await expect(ctx.agents.create({ agentId: AgentId('after-owner'), sessionId: SessionId('after-owner-s') }))
.rejects.toThrow(/no agent factory/)
})
})

View File

@@ -9,7 +9,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier uses a dedicated surrogate proxy target whose immutable filter slot cannot be replaced by a base property pinned before, during, or after construction; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The captured base filter and the exposed composed filter are invoked through captured JavaScript primordials, and the composed filter's frozen invocation surface cannot be replaced or tampered with. The carrier uses a dedicated surrogate proxy target; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate; defining through the carrier is therefore supported only with an explicit `configurable: true` descriptor, while an omitted or false flag is rejected before the base is touched. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.

View File

@@ -25,6 +25,14 @@
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from 'cordis'
// Capture the invocation primordials once. A carrier holder can reach the
// composed Context.filter function, so neither that function's mutable
// property surface nor a base filter's own `.call` may choose how isolation
// predicates are invoked.
const reflectApply = Reflect.apply
// eslint-disable-next-line @typescript-eslint/unbound-method
const functionCall = Function.prototype.call
/**
* The identity a scope is keyed by. Opaque and compared by object identity —
* never inspected. The harness convention: a live `Agent` is the key of its
@@ -194,8 +202,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean {
* - its tag IS `key` (a scoped listener seeing exactly its own subject),
*
* AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits
* it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a
* tool call with no calling agent or a bare (agent-less) session's events —
* it. Both the captured base filter and the composed filter are invoked
* through captured JavaScript primordials, so mutating either function's
* public `.call` property cannot bypass either predicate. Dispatching with
* `key === undefined` — a subject-less dispatch, e.g. a tool call with no
* calling agent or a bare (agent-less) session's events —
* admits only untagged listeners: a scoped listener never fires for someone
* else's (or nobody's) subject. Listeners registered `{ global: true }`
* bypass all filtering (Cordis semantics).
@@ -211,7 +222,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean {
* subject always travels in the event's arguments. The returned carrier is
* branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} /
* {@link carrierKeyOf}) so both the type system and the dev invariants can
* tell a carrier from a bare subject.
* tell a carrier from a bare subject. Defining an ordinary property through
* the carrier is supported only when its descriptor explicitly says
* `configurable: true`; an omitted or false flag is rejected before touching
* `base`, because the extensible surrogate cannot truthfully report a new
* non-configurable base property.
* @param base - the object the event is dispatched on behalf of (the owning
* service, or the subject agent itself); its own `Context.filter` is
* preserved and composed.
@@ -225,10 +240,19 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
throw new TypeError('scope target Context.filter must be a function when present')
}
const filter = (ctx: Context): boolean => {
if (baseFilter && !baseFilter.call(base, ctx)) return false
if (baseFilter && !reflectApply(functionCall, baseFilter, [base, ctx])) return false
const tag = scopeOf(ctx)
return tag === undefined || tag === key
}
// Cordis invokes a dispatch filter as `filter.call(thisArg, listenerCtx)`.
// Pin that property to the captured primordial, then freeze the callable so
// a carrier holder cannot replace it with an always-true scope bypass.
Object.defineProperty(filter, 'call', {
value: functionCall,
writable: false,
configurable: false,
})
Object.freeze(filter)
const overlay: Record<string | symbol, unknown> = {
[CordisContext.filter]: filter,
[kCarrier]: Object.freeze({ key }),
@@ -317,7 +341,7 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
return undefined
},
defineProperty(_target, prop, attributes) {
if (Object.hasOwn(overlay, prop)) return false
if (Object.hasOwn(overlay, prop) || attributes.configurable !== true) return false
return Reflect.defineProperty(base, prop, attributes)
},
deleteProperty(_target, prop) {

View File

@@ -189,10 +189,54 @@ describe('scopeTarget dispatch filtering', () => {
ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed')
expect(heard).toEqual([])
// A base whose filter accepts delegates to the scope predicate.
const openBase = { [Context.filter]: () => true }
// A base whose filter accepts delegates to the scope predicate, with the
// real base preserved as its `this` receiver.
let baseReceiverWasOpen = false
const openBase = {
[Context.filter](this: object): boolean {
baseReceiverWasOpen = this === openBase
return true
},
}
ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open')
expect(heard).toEqual(['global:open', 'A:open'])
expect(baseReceiverWasOpen).toBe(true)
// A function's public `.call` property is not its invocation semantics.
// An always-true replacement must not override the base predicate's veto.
const tamperedVeto = (): boolean => false
Object.defineProperty(tamperedVeto, 'call', { value: () => true })
ctx.emit(scopeTarget({ [Context.filter]: tamperedVeto }, keyA), 'scope-test/ping', 'tampered-veto')
expect(heard).toEqual(['global:open', 'A:open'])
})
it('pins the exposed composed filter invocation so a carrier holder cannot bypass isolation', async () => {
const ctx = new Context()
const keyA = { name: 'A' }
const keyB = { name: 'B' }
const scopeA = await mintScope(ctx, keyA)
const scopeB = await mintScope(ctx, keyB)
const heard: string[] = []
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
const carrier = scopeTarget(ctx, keyA)
const exposedFilter: unknown = Reflect.get(carrier, Context.filter)
expect(typeof exposedFilter).toBe('function')
const filter = exposedFilter as ((ctx: Context) => boolean) & { call: (...args: unknown[]) => unknown }
const primordialCall: unknown = Reflect.get(Function.prototype, 'call')
expect(Object.getOwnPropertyDescriptor(filter, 'call')).toMatchObject({
value: primordialCall,
writable: false,
configurable: false,
})
expect(Object.isFrozen(filter)).toBe(true)
expect(Reflect.set(filter, 'call', () => true)).toBe(false)
expect(Reflect.defineProperty(filter, 'call', { value: () => true })).toBe(false)
ctx.emit(carrier, 'scope-test/ping', 'still-A-only')
expect(heard).toEqual(['global:still-A-only', 'A:still-A-only'])
})
it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => {
@@ -256,6 +300,14 @@ describe('scopeTarget dispatch filtering', () => {
Object.defineProperty(carrier, 'extra', { value: 1, configurable: true })
expect((base as typeof base & { extra?: number }).extra).toBe(1)
expect(delete (carrier as typeof carrier & { extra?: number }).extra).toBe(true)
// A non-configurable property cannot be reflected truthfully through the
// extensible surrogate. Reject before mutating the delegated base; an
// omitted `configurable` has JavaScript's false default and is rejected too.
expect(Reflect.defineProperty(carrier, 'sealed', { value: 1, configurable: false })).toBe(false)
expect(Object.hasOwn(base, 'sealed')).toBe(false)
expect(Reflect.defineProperty(carrier, 'default-sealed', { value: 2 })).toBe(false)
expect(Object.hasOwn(base, 'default-sealed')).toBe(false)
expect(Reflect.preventExtensions(carrier)).toBe(false)
expect(Reflect.setPrototypeOf(carrier, null)).toBe(false)
})

View File

@@ -19,9 +19,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store-owned append observer detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. Until `release()` or owner unload, bare `prepare`/`create`/`enter` calls for that id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal.
- `ctx.sessions.enter(session, reservation?): () => void` — install the module-private `session/event` observer, capture its scope carrier, and add the session under one accepted id; returns the idempotent DETACH disposer, which clears notification, carrier, and accepted-key state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. Its detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. `release` is the exact owner effect disposer, so the agent lifecycle can adopt it and keep the ID reserved until scope cleanup quiesces. Until that release, bare `prepare`/`create`/`enter` calls for the id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal.
- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private `session/event` observer and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears notification, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.

View File

@@ -37,7 +37,9 @@ declare module 'cordis' {
* A session was created in the store. A synchronous listener throw vetoes
* publication and rollback emits the matching `session/disposed` edge;
* returned-promise rejection is observed and logged but cannot retroactively
* veto this synchronous boundary.
* veto this synchronous boundary. A synchronous listener that requests the
* advanced detach does not remove the entry immediately: removal and the
* paired `session/disposed` edge wait until the creation dispatch unwinds.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
@@ -648,7 +650,9 @@ export interface SessionRegistrationReservation {
prepare(options?: CreateSessionOptions): Session
/**
* Release the unpublished reservation; idempotent. The store also releases
* it automatically when the fiber that called `reserve` disposes.
* it automatically when the fiber that called `reserve` disposes. This
* function is that exact Cordis effect disposer, so an ordered lifecycle may
* yield it by identity and place release after quiescence.
* @returns nothing.
*/
release(): void
@@ -662,10 +666,16 @@ export interface SessionRegistrationReservation {
*/
export class SessionStore extends Service {
private store = new Map<SessionId, Session>()
/** Ids claimed across caller-code boundaries before their exact entry commits. */
private enteringIds = new Set<SessionId>()
/** The one accepted map key for each live session; never reread caller state. */
private acceptedIds = new WeakMap<Session, SessionId>()
/** Sessions whose creation announcement began and therefore require a pair. */
private announced = new WeakSet<Session>()
/** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */
private announcing = new WeakSet<Session>()
/** A detach requested reentrantly from `session/created`. */
private pendingDetach = new WeakSet<Session>()
/** Unpublished identities held across factory load/setup transactions. */
private reservations = new Map<SessionId, SessionRegistrationReservation>()
/** The exact prepared object owned by each reservation capability. */
@@ -696,18 +706,19 @@ export class SessionStore extends Service {
*/
reserve(id: SessionId): SessionRegistrationReservation {
if (typeof id !== 'string') throw new TypeError('session id must be a string')
if (this.store.has(id) || this.reservations.has(id)) {
if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) {
throw new Error(`session "${id}" already exists or is reserved`)
}
let active = true
let prepared = false
const rawRelease = (): void => {
if (!active) return
active = false
this.reservedSessions.delete(reservation)
this.reservations.delete(id)
}
let disposeEffect!: () => Promise<void> | void
// `release` is the exact effect disposer, so an ordered composite can
// adopt the automatic owner cleanup instead of racing it as a sibling.
const release = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`)
const reservation: SessionRegistrationReservation = Object.freeze({
id,
prepare: (options?: CreateSessionOptions) => {
@@ -720,20 +731,9 @@ export class SessionStore extends Service {
this.reservedSessions.set(reservation, session)
return session
},
release: () => {
rawRelease()
// Remove the now-inert ownership effect on manual transaction settle;
// its cleanup is the exact idempotent raw release above.
void disposeEffect()
},
release,
})
this.reservations.set(id, reservation)
try {
disposeEffect = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`)
} catch (error: unknown) {
rawRelease()
throw error
}
return reservation
}
@@ -845,7 +845,9 @@ export class SessionStore extends Service {
* @param session - a {@link prepare}d session not yet in the store.
* @param reservation - the exact unpublished-id capability when a factory
* reserved this session across setup.
* @returns the detach disposer (observer + store removal).
* @returns the detach disposer (observer + store removal). When called from
* a synchronous `session/created` listener, removal and disposal wait until
* that creation dispatch unwinds.
* @throws if a session with this id is already in the store.
*/
enter(session: Session, reservation?: SessionRegistrationReservation): () => void {
@@ -858,30 +860,71 @@ export class SessionStore extends Service {
|| this.reservedSessions.get(reservation) !== session) {
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
}
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
if (this.store.has(id) || this.enteringIds.has(id)) {
throw new Error(`session "${id}" already exists`)
}
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
this.enteringIds.add(id)
// The carrier is decided HERE, once, from the ENTERING context's scope tag
// (`this.ctx` is the caller's context — the tracker mechanism): every
// session/created|event|flush dispatch for this session uses it, so the
// session's whole event feed is scope-filtered consistently. The base is
// the session itself (scoped listeners' `this` is the session).
const carrier = scopeTarget(session, scopeOf(this.ctx))
let carrier: Scoped<Session>
try {
carrier = scopeTarget(session, scopeOf(this.ctx))
} finally {
this.enteringIds.delete(id)
}
const currentReservation = this.reservations.get(id)
if (reservation === undefined) {
/* v8 ignore next 2 -- reserve() rejects enteringIds, so carrier
* construction cannot install a new same-id reservation */
if (currentReservation !== undefined) {
throw new Error(`session "${id}" is reserved for unpublished creation`)
}
} else if (currentReservation !== reservation
|| this.reservedSessions.get(reservation) !== session) {
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
}
/* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
this.carriers.set(session, carrier)
const emitCtx = this.ctx
appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) })
this.acceptedIds.set(session, id)
this.store.set(id, session)
let entered = true
return () => {
const detach = (): void => {
if (!entered) return
entered = false
const wasAnnounced = this.announced.delete(session)
appendObservers.delete(session)
this.acceptedIds.delete(session)
this.carriers.delete(session)
this.store.delete(id)
if (wasAnnounced) this.emitDisposed(session, carrier, id)
// A creation listener may own the advanced detach capability. Keep the
// entry and its event observer live until the synchronous creation
// dispatch unwinds, then publish the paired disposal edge.
if (this.announcing.has(session)) {
this.pendingDetach.add(session)
return
}
this.detachEntered(session, id, carrier)
}
return detach
}
/** Remove one exact entered session and emit its paired disposal when announced. */
private detachEntered(session: Session, id: SessionId, carrier: Scoped<Session>): void {
this.pendingDetach.delete(session)
// A stale capability cannot remove observers or storage belonging to a
// later same-id lifecycle.
/* v8 ignore next 1 -- the commit claim makes replacement impossible; this
* remains the exact-identity backstop against future mutation paths */
if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return
const wasAnnounced = this.announced.delete(session)
appendObservers.delete(session)
this.acceptedIds.delete(session)
this.carriers.delete(session)
this.store.delete(id)
if (wasAnnounced) this.emitDisposed(session, carrier, id)
}
/** Emit `session/created` exactly once for an {@link enter}ed session (with
@@ -892,25 +935,31 @@ export class SessionStore extends Service {
* @throws if the session is not live or its announcement already began,
* including a reentrant call from a creation listener. */
announce(session: Session): void {
const carrier = this.liveCarrierFor(session)
const { carrier, id } = this.liveEntryFor(session)
if (this.announced.has(session)) {
throw new Error(`session "${session.id}" was already announced`)
throw new Error(`session "${id}" was already announced`)
}
// Mark before emit: Cordis emit may deliver to earlier listeners and then
// throw. Rollback must still pair that partial creation with disposal, and
// a listener cannot recursively create a second lifecycle edge.
this.announced.add(session)
const args: unknown[] = [carrier, 'session/created', session]
for (const callback of this.ctx.events.dispatch('emit', args)) {
// Synchronous throws intentionally propagate and veto publication; the
// yielded detach then emits the paired disposal edge. An async function
// is nevertheless assignable to a void listener, so observe its returned
// promise: rejection is too late to roll back and must be logged instead
// of becoming unhandled.
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`session "${session.id}": session/created listener rejected: ${renderThrown(error)}`)
})
this.announcing.add(session)
try {
for (const callback of this.ctx.events.dispatch('emit', args)) {
// Synchronous throws intentionally propagate and veto publication; the
// yielded detach then emits the paired disposal edge. An async function
// is nevertheless assignable to a void listener, so observe its returned
// promise: rejection is too late to roll back and must be logged instead
// of becoming unhandled.
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`session "${id}": session/created listener rejected: ${renderThrown(error)}`)
})
}
} finally {
this.announcing.delete(session)
if (this.pendingDetach.has(session)) this.detachEntered(session, id, carrier)
}
}
@@ -940,11 +989,11 @@ export class SessionStore extends Service {
* @returns resolves when every flush listener has settled; rejects if one rejects.
*/
async flush(session: Session): Promise<void> {
await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
await this.ctx.parallel(this.liveEntryFor(session).carrier, 'session/flush', session)
}
/** Return the exact live session's carrier; detached/prepared objects reject. */
private liveCarrierFor(session: Session): Scoped<Session> {
/** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */
private liveEntryFor(session: Session): { id: SessionId; carrier: Scoped<Session> } {
const id = this.acceptedIds.get(session)
if (id === undefined || this.store.get(id) !== session) {
throw new Error(`session "${id ?? session.id}" is not live in this store`)
@@ -957,7 +1006,7 @@ export class SessionStore extends Service {
if (carrier === undefined) {
throw new Error(`session "${id}" has no dispatch carrier`)
}
return carrier
return { id, carrier }
}
/**

View File

@@ -740,6 +740,79 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
})
it('claims an id across Context.filter evaluation before committing the exact session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('reentrant-enter')
const nested = new Session(id)
const outer = new Session(id)
let nestedError = ''
let attempted = false
Object.defineProperty(outer, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
try {
ctx.sessions.enter(nested)
} catch (error: unknown) {
nestedError = String(error)
}
}
return undefined
},
})
const detach = ctx.sessions.enter(outer)
expect(nestedError).toMatch(/already exists/)
expect(ctx.sessions.get(id)).toBe(outer)
detach()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('revalidates reservation ownership after carrier construction runs caller code', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('released-during-enter')
const reservation = ctx.sessions.reserve(id)
const session = reservation.prepare()
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
reservation.release()
return undefined
},
})
expect(() => ctx.sessions.enter(session, reservation)).toThrow(/does not own this prepared session/)
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('rejects when carrier construction attaches the same session to another store', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('cross-store-carrier'))
let attempted = false
let detachSecond = (): void => {}
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
detachSecond = secondCtx.sessions.enter(session)
}
return undefined
},
})
expect(() => firstCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(session.id)).toBeUndefined()
expect(secondCtx.sessions.get(session.id)).toBe(session)
detachSecond()
})
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -869,6 +942,49 @@ describe('SessionStore', () => {
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
})
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const order: string[] = []
const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
const detach = ctx.sessions.enter(session)
ctx.on('session/created', (created) => {
order.push('created:first')
detach()
expect(ctx.sessions.get(created.id)).toBe(created)
})
ctx.on('session/created', (created) => {
order.push('created:second')
expect(ctx.sessions.get(created.id)).toBe(created)
})
ctx.on('session/disposed', (disposed) => {
order.push('disposed')
expect(ctx.sessions.get(disposed.id)).toBeUndefined()
})
ctx.sessions.announce(session)
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
expect(ctx.sessions.get(session.id)).toBeUndefined()
detach()
})
it('rolls back create when its owner unloads from session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let ownerCtx!: Context
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
const id = SessionId('create-unload-race')
ctx.on('session/created', (session) => {
if (session.id === id) void owner.dispose()
})
ownerCtx.sessions.create(id)
await owner.dispose()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('synthesizes a minimal current-version header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -13,7 +13,7 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the unpublished owner so no agent, session, or lifecycle event can escape; after readiness it cancels the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the creation owner: before creation notification begins, no agent/session lifecycle edge escapes; if cancellation is triggered synchronously by a creation observer, every begun edge is paired by rollback and the driver never unlocks or starts. After readiness, cancellation reaches the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
### `InProcessRunOptions`

View File

@@ -23,7 +23,7 @@ What the seam guarantees regardless, because benign scripts hit these constantly
`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (unbuilt: a JavaScript data-URL bootstrap registers tsx's ESM and CommonJS transforms inside the worker before importing `src/worker.ts`, giving the whole mixed-module source graph full TypeScript and tsconfig-path transformation on every supported Node line; built: the sibling `lib/worker.js` bundle) with the meta, body, `args`, and worker-side limits as `workerData`.
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child on `ctx.subagents` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through.
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child through the holder-bound `SubagentService` handle captured synchronously by `start()`, with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. This capture is part of the seam's holder-owned lifetime: unloading the engine removes `ctx.workflows` for new calls but does not invalidate an already returned run whose worker starts another child afterward.
The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. It then replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. A readiness rejection replies `child-start-error`, emits no workflow agent pair, and makes the host dispose the attempt because the worker never received a handle; the worker classifies it as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC.

View File

@@ -1,9 +1,10 @@
/**
* The host half of one worker-engine run: spawn the Worker, bridge its child
* RPC onto `ctx.subagents`, fan its observer messages into the engine's
* events, and own cancellation, the settle-within-grace guarantee, and child
* cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always
* ends with `worker.terminate()`, so no thread outlives its run.
* RPC onto the holder-bound subagent service, fan its observer messages into
* the engine's events, and own cancellation, the settle-within-grace
* guarantee, and child cleanup. The worker's lifetime IS the run's lifetime:
* `dispose()` always ends with `worker.terminate()`, so no thread outlives its
* run.
*
* The run's `result` promise settles exactly once, from whichever of these
* lands first: the worker's `result` message (a host-side cancellation in
@@ -47,6 +48,7 @@ import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentRun } from '@deepseek-ai/dsh-subagent'
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import { renderThrown } from './realm.ts'
@@ -121,7 +123,9 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti
* `start()` directly. Owns the Worker, the child registry, and the result
* settlement; `result` never rejects. `meta` is this handle's OWN clone
* (event payloads carry separate clones), so a consumer mutating it corrupts
* nothing.
* nothing. The holder-bound SubagentService handle is captured before the
* engine returns this run, so unloading the engine removes only the ability to
* start another workflow; this run can still start and clean up its children.
*/
export class WorkerRun implements WorkflowRun {
/** Settles exactly once with the run's outcome; never rejects. */
@@ -151,6 +155,7 @@ export class WorkerRun implements WorkflowRun {
constructor(
private readonly ctx: Context,
private readonly subagents: SubagentService,
readonly id: WorkflowRunId,
readonly meta: WorkflowMeta,
private readonly parent: Agent,
@@ -329,7 +334,7 @@ export class WorkerRun implements WorkflowRun {
this.hostStarted += 1
let run: SubagentRun
try {
run = this.ctx.subagents.start(this.provider, {
run = this.subagents.start(this.provider, {
prompt: [{ type: 'text', text: request.prompt }],
parent: this.parent,
signal: this.controller.signal,

View File

@@ -168,8 +168,17 @@ export class WorkerWorkflowEngine extends WorkflowService {
...request.args !== undefined ? { args: request.args } : {},
limits,
}
// Capture the dependency while this service call is still traced through
// the start() holder. Cordis strips the engine-provider shadow when it
// returns the SubagentService handle, so an already-returned run can keep
// starting children after an engine HMR unload removes ctx.workflows.
// Re-resolving `this.ctx.subagents` later from WorkerRun would instead walk
// the now-inactive engine fiber and break the seam's holder-owned lifetime.
const runCtx = this.ctx
const subagents = runCtx.subagents
const workerRun = new WorkerRun(
this.ctx,
runCtx,
subagents,
id,
structuredClone(meta),
request.parent,

View File

@@ -148,8 +148,8 @@ async function setup(options?: SetupOptions) {
// A fixed concurrency ceiling: the auto-resolved default is machine-derived
// (cores - 2, floored at 1), so tests that expect N children in flight
// would wedge on small CI runners.
await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config })
return { ctx, provider, parent: fakeParent() }
const engineFiber = await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config })
return { ctx, provider, parent: fakeParent(), engineFiber }
}
/** The standard test meta plus a body, spread into a start request. */
@@ -1235,6 +1235,34 @@ describe('dsh-workflow-workerthread', () => {
expect(ctx.get('workflows')).toBeUndefined()
})
it('keeps a holder-owned run usable when the engine unloads before its child starts', async () => {
const { ctx, parent, provider, engineFiber } = await setup({ reply: () => text('survived reload') })
let handle!: ReturnType<typeof ctx.workflows.start>
const holder = await ctx.plugin(Object.assign((inner: Context) => {
handle = inner.workflows.start({ ...scripted("return await agent('after reload')"), parent })
}, { inject: ['workflows'] }))
try {
// A real worker cannot deliver child-start in the synchronous start()
// slice. Unload the provider before that message arrives: the returned
// run belongs to `holder`, not to the engine fiber being reloaded.
expect(provider.runs).toHaveLength(0)
await engineFiber.dispose()
expect(ctx.get('workflows')).toBeUndefined()
await expect(handle.result).resolves.toEqual({
value: 'survived reload',
stopReason: 'completed',
agentsStarted: 1,
})
expect(provider.runs).toHaveLength(1)
} finally {
await handle.dispose()
await holder.dispose()
await ctx.fiber.dispose()
}
})
it('has the class-plugin export shape (default = the engine service class)', () => {
expect(workerEngineModule.default).toBe(WorkerWorkflowEngine)
const loader = Object.create(Loader.prototype) as Loader