fix(scope): harden final ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 05:13:17 +08:00
parent 36b8370027
commit a9cb70d896
52 changed files with 2839 additions and 514 deletions

View File

@@ -7,10 +7,10 @@
*/
import type { Context } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
@@ -65,6 +65,26 @@ export function prepareReactLoopAgent(
}
}
/**
* Install the concrete agent's scope context exactly once. Construction and
* scope minting are mutually referential (the scope key is the agent), so the
* factory performs this one post-construction binding before setup receives
* the unpublished agent. The runtime slot is non-writable/non-configurable;
* TypeScript `readonly` alone would still let JavaScript redirect later
* registrations to another context.
* @param agent - the unpublished concrete agent to bind.
* @param ctx - its fully extended agent scope context.
*/
export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void {
if (Object.hasOwn(agent, 'ctx')) throw new Error(`agent "${agent.id}" context is already bound`)
Object.defineProperty(agent, 'ctx', {
value: ctx,
enumerable: true,
writable: false,
configurable: false,
})
}
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
*
@@ -84,18 +104,7 @@ export class ReactLoopAgent implements Agent {
* context are mutually referential (the scope is keyed BY this agent), so
* neither can exist strictly before the other.
*/
ctx!: Context
/**
* The dispatch carrier for this agent's own emits (`agent/status`,
* `agent/queued`, `agent/error`): keyed by the agent, base = the agent
* (listener `this` is the agent). Built lazily because it is self-referential.
*/
private get carrier(): Scoped<Agent> {
return (this.#carrier ??= scopeTarget(this, this))
}
#carrier: Scoped<Agent> | undefined
declare readonly ctx: Context
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
@@ -143,6 +152,16 @@ export class ReactLoopAgent implements Agent {
public readonly options: AgentOptions,
public readonly session: Session,
) {
const acceptedOptions = deepFreeze(structuredClone(options))
// Pin the public ownership/identity bindings in the runtime object. A
// JavaScript caller can otherwise replace TS-readonly parameter properties
// after publication and split the registry, driver, session, and model
// configuration into different worlds.
Object.defineProperties(this, {
id: { value: id, enumerable: true, writable: false, configurable: false },
options: { value: acceptedOptions, enumerable: true, writable: false, configurable: false },
session: { value: session, enumerable: true, writable: false, configurable: false },
})
const { promise, resolve } = Promise.withResolvers<void>()
this.disposed = promise
this.resolveDisposed = resolve
@@ -161,11 +180,7 @@ export class ReactLoopAgent implements Agent {
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
if (status !== 'running') this.settleIdleWaiters()
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, status)
} catch (error: unknown) {
this.loopCtx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
}
agentEvents(this.loopCtx, this).emit('agent/status', status)
}
/**
@@ -194,7 +209,7 @@ export class ReactLoopAgent implements Agent {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = this.resolveSource(options)
this.#inbox.enqueue({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: false })
}
steer(content: ContentBlock[], options?: SendOptions): void {
@@ -203,7 +218,7 @@ export class ReactLoopAgent implements Agent {
if (this._status !== 'running') { this.send(content, options); return }
const source = this.resolveSource(options)
this.#inbox.steer({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: true })
}
inject(content: ContentBlock[], options?: SendOptions): void {
@@ -269,14 +284,10 @@ export class ReactLoopAgent implements Agent {
if (turnRecorded) {
// Through the store's flush (the carrier owner), never a raw parallel.
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error))
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`)
try {
this.loopCtx.emit(this.carrier, 'agent/error', this, turn, 0, err)
} catch {
// contained: the failure is already logged; a throwing agent/error
// listener must not escape this fire-and-forget catch.
}
const rendered = renderThrown(error)
const err = error instanceof Error ? error : new Error(rendered)
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
})
this.pendingIdleFlushes.add(flush)
// Attach the same retirement callback to both settlement arms so even a
@@ -393,11 +404,7 @@ export class ReactLoopAgent implements Agent {
// 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.
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed')
} catch {
// listener error during disposal — nothing safe left to do with it
}
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
@@ -414,3 +421,12 @@ export class ReactLoopAgent implements Agent {
}
}
}
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? value.message : String(value)
} catch {
return '<unrenderable thrown value>'
}
}

View File

@@ -13,17 +13,24 @@ import z from 'schemastery'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, AgentRegistrationReservation, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Session, SessionRegistrationReservation } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
import { bindReactLoopAgentContext, prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
export { ReactLoopAgent } from './agent.ts'
/** Both unpublished identity capabilities held by one factory transaction. */
interface RegistrationReservations {
agent: AgentRegistrationReservation
session: SessionRegistrationReservation
release(): void
}
declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
@@ -71,10 +78,6 @@ export interface Config {
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
/** IDs held by unpublished async creation transactions. */
private pendingAgentIds = new Set<AgentId>()
private pendingSessionIds = new Set<SessionId>()
// The schema validates plain strings (cordis.yml config values are untyped at
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
// because the config format is the boundary where an id enters. The brand is a
@@ -153,14 +156,19 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the running agent, owned by the calling fiber (no handle).
*/
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
const reservations = this.reserve(id, sessionId)
// Config/programmatic path: prepare the session and let start() fold its
// 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.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
const { agent } = this.start(id, options, session, 'startup')
return agent
try {
const session = reservations.session.prepare({ meta })
const { agent } = this.start(id, options, session, 'startup', reservations)
return agent
} finally {
reservations.release()
}
}
/**
@@ -188,16 +196,16 @@ export class AgentLoop extends Service implements AgentFactory {
const agentOptions = structuredClone(options.agentOptions ?? {})
const seed = options.seed
const meta = options.meta
const release = this.reserve(agentId, sessionId)
const reservations = this.reserve(agentId, sessionId)
try {
const session = this.ctx.sessions.prepare(sessionId, {
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', setup)
return await this.startOwned(agentId, agentOptions, session, 'startup', reservations, setup)
} finally {
release()
reservations.release()
}
}
@@ -273,7 +281,7 @@ export class AgentLoop extends Service implements AgentFactory {
return transactionSettled
}, `agentLoop.resumeLoad(${agentId})`)
try {
const release = this.reserve(agentId, sessionId)
const reservations = this.reserve(agentId, sessionId)
try {
const loadTask = persistence.load(sessionId)
const { meta, events } = await Promise.race([
@@ -292,7 +300,7 @@ export class AgentLoop extends Service implements AgentFactory {
// An out-of-band direct registry/session insertion can still race this
// service's reservation, so the public enter primitives re-check exact
// liveness at publication.
const session = this.ctx.sessions.prepare(sessionId, {
const session = reservations.session.prepare({
seed: events,
meta: {
createdAt,
@@ -305,12 +313,12 @@ export class AgentLoop extends Service implements AgentFactory {
// 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', setup)
const starting = this.startOwned(agentId, agentOptions, session, 'resume', reservations, setup)
observingOwner = false
await disposeLoadSentinel()
return await starting
} finally {
release()
reservations.release()
}
} finally {
try {
@@ -327,29 +335,24 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/**
* Reject a duplicate agent id BEFORE the session is entered into the store, so
* a failed factory call never leaves an orphaned live session (and lazy
* persistence state) behind. `register()` enforces the same uniqueness, but
* only after the session has already entered the store.
*/
private assertAgentIdFree(id: AgentId): void {
if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
}
/** Reserve both public identities for one unpublished async transaction. */
private reserve(agentId: AgentId, sessionId: SessionId): () => void {
this.assertAgentIdFree(agentId)
if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) {
throw new Error(`session "${sessionId}" already exists`)
}
this.pendingAgentIds.add(agentId)
this.pendingSessionIds.add(sessionId)
return () => {
this.pendingAgentIds.delete(agentId)
this.pendingSessionIds.delete(sessionId)
/** Reserve both public identities in their owning registries. */
private reserve(agentId: AgentId, sessionId: SessionId): RegistrationReservations {
const agent = this.ctx.agents.reserve(agentId)
try {
const session = this.ctx.sessions.reserve(sessionId)
return {
agent,
session,
release() {
// Both owner capabilities are independently idempotent, so the
// composite needs no second state machine of its own.
session.release()
agent.release()
},
}
} catch (error: unknown) {
agent.release()
throw error
}
}
@@ -361,7 +364,12 @@ export class AgentLoop extends Service implements AgentFactory {
* `active`, unwinds the scope, and wins the race without any late Cordis
* effect collection.
*/
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
private prepareLifecycle(
id: AgentId,
options: AgentOptions,
session: Session,
reservations: RegistrationReservations,
): {
agent: ReactLoopAgent
active: () => boolean
deactivated: Promise<void>
@@ -378,7 +386,7 @@ export class AgentLoop extends Service implements AgentFactory {
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
const { agent } = driver
const scope: Scope = createScope(this.ctx, agent)
agent.ctx = scope.ctx.extend({ agent })
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
let active = true
let detachSession: (() => void) | undefined
@@ -422,19 +430,15 @@ export class AgentLoop extends Service implements AgentFactory {
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)
detachAgent = this.ctx.agents.enter(agent)
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()
try {
agentEvents(this.ctx, agent).emit('agent/session-start', source)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
}
agentEvents(this.ctx, agent).emit('agent/session-start', source)
stop = driver.startDriver()
}
@@ -453,9 +457,13 @@ export class AgentLoop extends Service implements AgentFactory {
/** Publish a no-setup config agent synchronously. */
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
id: AgentId,
options: AgentOptions,
session: Session,
source: SessionStartSource,
reservations: RegistrationReservations,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const lifecycle = this.prepareLifecycle(id, options, session)
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
try {
lifecycle.publish(source)
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
@@ -485,9 +493,10 @@ export class AgentLoop extends Service implements AgentFactory {
*/
private async startOwned(
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)
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
try {
// The owner-disposal branch makes a never-settling setup unable to hold
// the transaction or its ID reservations forever. Promise.race installs