fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -169,9 +169,11 @@ export class SubagentService extends Service {
|
||||
* Register a provider under its `provider.name`. Throws {@link SubagentError}
|
||||
* (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots
|
||||
* the name, static descriptors, and `start` callback identity at acceptance;
|
||||
* later caller mutation cannot change lookup, capability validation, consumer
|
||||
* wording, dispatch, or HMR cleanup. The callback remains bound to the
|
||||
* original provider object, so provider-owned mutable state stays live.
|
||||
* every fixed field and capability flag is read once and validated before
|
||||
* registration, so malformed provider objects fail loud without entering the
|
||||
* registry. Later caller mutation cannot change lookup, capability validation,
|
||||
* consumer wording, dispatch, or HMR cleanup. The callback remains bound to
|
||||
* the original provider object, so provider-owned mutable state stays live.
|
||||
* Effect-scoped: disposed with the calling fiber (HMR-safe). Emits
|
||||
* `subagent/provider-added` after the registration and
|
||||
* `subagent/provider-removed` on unregistration, so consumers can mirror
|
||||
@@ -187,18 +189,49 @@ export class SubagentService extends Service {
|
||||
// mutate or reuse the provider object before its old fiber unloads. Binding
|
||||
// preserves the provider method's receiver while making replacement of the
|
||||
// public callback field after registration inert.
|
||||
const inputCapabilities = provider.capabilities
|
||||
const name: unknown = provider.name
|
||||
const inputCapabilities: unknown = provider.capabilities
|
||||
const inheritsParentContext: unknown = provider.inheritsParentContext
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputStart: unknown = provider.start
|
||||
if (typeof name !== 'string') {
|
||||
throw new TypeError('subagent provider name must be a string')
|
||||
}
|
||||
if (inputCapabilities === null || typeof inputCapabilities !== 'object' || Array.isArray(inputCapabilities)) {
|
||||
throw new TypeError(`subagent provider "${name}" capabilities must be an object`)
|
||||
}
|
||||
const inputCapabilityFields = inputCapabilities as Record<keyof SubagentCapabilities, unknown>
|
||||
const outputSchema = inputCapabilityFields.outputSchema
|
||||
const depthLimit = inputCapabilityFields.depthLimit
|
||||
const toolFilter = inputCapabilityFields.toolFilter
|
||||
const persona = inputCapabilityFields.persona
|
||||
for (const [capability, value] of [
|
||||
['outputSchema', outputSchema],
|
||||
['depthLimit', depthLimit],
|
||||
['toolFilter', toolFilter],
|
||||
['persona', persona],
|
||||
] as const) {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new TypeError(`subagent provider "${name}" capability "${capability}" must be a boolean`)
|
||||
}
|
||||
}
|
||||
if (typeof inheritsParentContext !== 'boolean') {
|
||||
throw new TypeError(`subagent provider "${name}" inheritsParentContext must be a boolean`)
|
||||
}
|
||||
if (typeof inputStart !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" start must be a function`)
|
||||
}
|
||||
const capabilities: SubagentCapabilities = Object.freeze({
|
||||
outputSchema: inputCapabilities.outputSchema,
|
||||
depthLimit: inputCapabilities.depthLimit,
|
||||
toolFilter: inputCapabilities.toolFilter,
|
||||
persona: inputCapabilities.persona,
|
||||
outputSchema: outputSchema as boolean,
|
||||
depthLimit: depthLimit as boolean,
|
||||
toolFilter: toolFilter as boolean,
|
||||
persona: persona as boolean,
|
||||
})
|
||||
const snapshot: SubagentProvider = Object.freeze({
|
||||
name: provider.name,
|
||||
name,
|
||||
capabilities,
|
||||
inheritsParentContext: provider.inheritsParentContext,
|
||||
start: provider.start.bind(provider),
|
||||
inheritsParentContext,
|
||||
start: Function.prototype.bind.call(inputStart, provider) as SubagentProvider['start'],
|
||||
})
|
||||
const dispose = this.ctx.effect(function* (this: SubagentService) {
|
||||
if (this.providers.has(snapshot.name)) {
|
||||
@@ -255,7 +288,10 @@ export class SubagentService extends Service {
|
||||
* {@link SubagentProvider.start}. The returned handle is a service-owned,
|
||||
* frozen wrapper: provider fields are captured once, methods stay bound to the
|
||||
* provider handle, and `result` resolves to one detached, deeply frozen value
|
||||
* shared by the caller and lifecycle telemetry. Emits `subagent/start` /
|
||||
* shared by the caller and lifecycle telemetry. Once a provider returns a
|
||||
* callable disposer, malformed handle access/binding starts rollback before
|
||||
* the synchronous fault escapes; malformed terminal data rejects only after
|
||||
* that same memoized disposal reaches quiescence. Emits `subagent/start` /
|
||||
* `subagent/end` only after the run's readiness boundary fulfills. A provider
|
||||
* that fails before establishing a child emits neither event.
|
||||
* @param name - the provider to run on.
|
||||
@@ -324,82 +360,176 @@ export class SubagentService extends Service {
|
||||
...toolFilter !== undefined ? { toolFilter } : {},
|
||||
...input.persona !== undefined ? { persona: input.persona } : {},
|
||||
}
|
||||
const providerRun = provider.start(accepted)
|
||||
const providerRun: unknown = provider.start(accepted)
|
||||
if (providerRun === null || (typeof providerRun !== 'object' && typeof providerRun !== 'function')) {
|
||||
throw new TypeError(`subagent provider "${name}" start must return a SubagentRun object`)
|
||||
}
|
||||
const acceptedRun = providerRun as SubagentRun
|
||||
// Acquire the one rollback capability BEFORE touching any other provider-run
|
||||
// field. Once start() returned a handle, the service owns an accepted live
|
||||
// attempt; a hostile later accessor or bind must not make that attempt
|
||||
// unreachable. The wrapper also memoizes provider disposal, so automatic
|
||||
// rollback and a racing caller join one quiescence transaction even if a
|
||||
// contract-violating provider forgot to make its own method idempotent.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputDispose = acceptedRun.dispose
|
||||
if (typeof inputDispose !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run dispose must be a function`)
|
||||
}
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (): Promise<void> => {
|
||||
if (disposal === undefined) {
|
||||
try {
|
||||
// Invoke through the captured callable without reading its public
|
||||
// `bind`/`length`/`name` properties. Disposal is the recovery
|
||||
// capability itself; hostile function metadata must not prevent the
|
||||
// seam from exercising it when a later handle field is malformed.
|
||||
disposal = Promise.resolve(Reflect.apply(inputDispose, acceptedRun, []))
|
||||
} catch (error: unknown) {
|
||||
disposal = Promise.reject(error instanceof Error
|
||||
? error
|
||||
: new Error('subagent provider run dispose threw a non-Error value', { cause: error }))
|
||||
}
|
||||
}
|
||||
return disposal
|
||||
}
|
||||
// Provider-owned run objects can be accessor-backed too. Capture every
|
||||
// public field exactly once, bind methods to the provider's original handle,
|
||||
// and expose only this service-owned wrapper. The normalized result promise
|
||||
// is also the one lifecycle telemetry observes, so the caller and observers
|
||||
// cannot receive different values from stateful accessors.
|
||||
const id = providerRun.id
|
||||
const started = providerRun.started
|
||||
const providerResult = providerRun.result
|
||||
const cancel = providerRun.cancel.bind(providerRun)
|
||||
const sendMessage = providerRun.sendMessage?.bind(providerRun)
|
||||
const dispose = providerRun.dispose.bind(providerRun)
|
||||
const resume = providerRun.resume?.bind(providerRun)
|
||||
const result = providerResult.then(value => this.snapshotRunResult(value))
|
||||
const run: SubagentRun = Object.freeze({
|
||||
id,
|
||||
started,
|
||||
result,
|
||||
cancel,
|
||||
dispose,
|
||||
...sendMessage === undefined
|
||||
? {}
|
||||
: { sendMessage },
|
||||
...resume === undefined
|
||||
? {}
|
||||
: { resume },
|
||||
})
|
||||
|
||||
// Observe result settlement IMMEDIATELY, before waiting on readiness. A
|
||||
// provider may fail both promises in the same turn; deferring the rejection
|
||||
// handler until `started` fulfilled would leave `result` transiently
|
||||
// unhandled. The settled event is buffered until start has been announced,
|
||||
// preserving start → end order even for an already-settled scripted run.
|
||||
let readiness: 'pending' | 'started' | 'failed' = 'pending'
|
||||
let pendingEnd: SubagentRunEndInfo | undefined
|
||||
const deliverEnd = (info: SubagentRunEndInfo): void => {
|
||||
if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent)
|
||||
else if (readiness === 'pending') pendingEnd = info
|
||||
// A pre-publication readiness failure has no lifecycle pair; result
|
||||
// remains observable by the run's consumer, but telemetry must not claim
|
||||
// that a child started.
|
||||
}
|
||||
void result.then(
|
||||
(value) => {
|
||||
deliverEnd({
|
||||
provider: name,
|
||||
id,
|
||||
stopReason: value.stopReason,
|
||||
lastAssistantMessage: value.output,
|
||||
})
|
||||
},
|
||||
() => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
|
||||
)
|
||||
|
||||
// Readiness is the publication boundary owned by the provider. For
|
||||
// in-process runs, fulfillment means the agent registry already contains
|
||||
// `run.id`; for ACP it means the remote session exists. Emit start with
|
||||
// per-listener containment, then flush an outcome that settled unusually
|
||||
// early. A readiness rejection is handled here and deliberately emits no
|
||||
// false start/end pair; the result path above remains independently handled.
|
||||
void started.then(
|
||||
() => {
|
||||
readiness = 'started'
|
||||
this.emitLifecycle('subagent/start', { provider: name, id }, parent)
|
||||
if (pendingEnd !== undefined) {
|
||||
const info = pendingEnd
|
||||
pendingEnd = undefined
|
||||
this.emitLifecycle('subagent/end', info, parent)
|
||||
try {
|
||||
const id = acceptedRun.id
|
||||
if (typeof id !== 'string') {
|
||||
throw new TypeError(`subagent provider "${name}" run id must be a string`)
|
||||
}
|
||||
const started = acceptedRun.started
|
||||
if (!(started instanceof Promise)) {
|
||||
throw new TypeError(`subagent provider "${name}" run started must be a Promise`)
|
||||
}
|
||||
// Observe each accepted provider promise before reading the next hostile
|
||||
// field. A later accessor/validation failure prevents a wrapper from being
|
||||
// returned, but must not leave an already-rejected provider promise
|
||||
// unhandled while rollback proceeds.
|
||||
void started.catch(() => undefined)
|
||||
const providerResult = acceptedRun.result
|
||||
if (!(providerResult instanceof Promise)) {
|
||||
throw new TypeError(`subagent provider "${name}" run result must be a Promise`)
|
||||
}
|
||||
void providerResult.catch(() => undefined)
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputCancel = acceptedRun.cancel
|
||||
if (typeof inputCancel !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run cancel must be a function`)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputSendMessage = acceptedRun.sendMessage
|
||||
if (inputSendMessage !== undefined && typeof inputSendMessage !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run sendMessage must be a function when provided`)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputResume = acceptedRun.resume
|
||||
if (inputResume !== undefined && typeof inputResume !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run resume must be a function when provided`)
|
||||
}
|
||||
const cancel = Function.prototype.bind.call(inputCancel, acceptedRun) as SubagentRun['cancel']
|
||||
const sendMessage = inputSendMessage === undefined
|
||||
? undefined
|
||||
: Function.prototype.bind.call(inputSendMessage, acceptedRun) as NonNullable<SubagentRun['sendMessage']>
|
||||
const resume = inputResume === undefined
|
||||
? undefined
|
||||
: Function.prototype.bind.call(inputResume, acceptedRun) as NonNullable<SubagentRun['resume']>
|
||||
const result = providerResult.then(async (value) => {
|
||||
try {
|
||||
return this.snapshotRunResult(value)
|
||||
} catch (error: unknown) {
|
||||
// A malformed terminal value is an infrastructure contract fault. The
|
||||
// result rejects only after the accepted provider attempt has reached
|
||||
// quiescence, so a caller cannot lose the only cleanup handle by merely
|
||||
// observing the normalization failure.
|
||||
await this.rollbackProviderRun(name, dispose)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
() => {
|
||||
readiness = 'failed'
|
||||
pendingEnd = undefined
|
||||
},
|
||||
)
|
||||
return run
|
||||
})
|
||||
const run: SubagentRun = Object.freeze({
|
||||
id,
|
||||
started,
|
||||
result,
|
||||
cancel,
|
||||
dispose,
|
||||
...sendMessage === undefined
|
||||
? {}
|
||||
: { sendMessage },
|
||||
...resume === undefined
|
||||
? {}
|
||||
: { resume },
|
||||
})
|
||||
|
||||
// Observe result settlement IMMEDIATELY, before waiting on readiness. A
|
||||
// provider may fail both promises in the same turn; deferring the rejection
|
||||
// handler until `started` fulfilled would leave `result` transiently
|
||||
// unhandled. The settled event is buffered until start has been announced,
|
||||
// preserving start → end order even for an already-settled scripted run.
|
||||
let readiness: 'pending' | 'started' | 'failed' = 'pending'
|
||||
let pendingEnd: SubagentRunEndInfo | undefined
|
||||
const deliverEnd = (info: SubagentRunEndInfo): void => {
|
||||
if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent)
|
||||
else if (readiness === 'pending') pendingEnd = info
|
||||
// A pre-publication readiness failure has no lifecycle pair; result
|
||||
// remains observable by the run's consumer, but telemetry must not claim
|
||||
// that a child started.
|
||||
}
|
||||
void result.then(
|
||||
(value) => {
|
||||
deliverEnd({
|
||||
provider: name,
|
||||
id,
|
||||
stopReason: value.stopReason,
|
||||
lastAssistantMessage: value.output,
|
||||
})
|
||||
},
|
||||
() => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
|
||||
)
|
||||
|
||||
// Readiness is the publication boundary owned by the provider. For
|
||||
// in-process runs, fulfillment means the agent registry already contains
|
||||
// `run.id`; for ACP it means the remote session exists. Emit start with
|
||||
// per-listener containment, then flush an outcome that settled unusually
|
||||
// early. A readiness rejection is handled here and deliberately emits no
|
||||
// false start/end pair; the result path above remains independently handled.
|
||||
void started.then(
|
||||
() => {
|
||||
readiness = 'started'
|
||||
this.emitLifecycle('subagent/start', { provider: name, id }, parent)
|
||||
if (pendingEnd !== undefined) {
|
||||
const info = pendingEnd
|
||||
pendingEnd = undefined
|
||||
this.emitLifecycle('subagent/end', info, parent)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
readiness = 'failed'
|
||||
pendingEnd = undefined
|
||||
},
|
||||
)
|
||||
return run
|
||||
} catch (error: unknown) {
|
||||
// start() has already transferred a live attempt to the seam. Begin
|
||||
// rollback synchronously before surfacing the malformed-handle failure;
|
||||
// the contained cleanup promise prevents either a resource leak or an
|
||||
// unhandled rejection even though this API cannot synchronously await it.
|
||||
void this.rollbackProviderRun(name, dispose)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose one malformed provider attempt without letting cleanup mask the contract fault. */
|
||||
private async rollbackProviderRun(providerName: string, dispose: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await dispose()
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent provider "${providerName}" malformed run rollback failed: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize one provider result into the immutable seam value. */
|
||||
@@ -452,10 +582,12 @@ export class SubagentService extends Service {
|
||||
|
||||
/**
|
||||
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
|
||||
* each subscriber individually and log (never propagate) a thrown one, so one
|
||||
* bad subscriber can neither strand the already-live run, surface as an
|
||||
* unhandled rejection on the detached settle hook, NOR starve the listeners
|
||||
* registered after it. A single try/catch around `ctx.emit` would not do the
|
||||
* each subscriber individually and log (never propagate) either a synchronous
|
||||
* throw or a returned-promise rejection, so one bad subscriber can neither
|
||||
* strand the already-live run, surface as an unhandled rejection on the
|
||||
* detached settle hook, NOR starve the listeners registered after it. Async
|
||||
* listeners remain concurrent fire-and-forget; dispatch does not await or
|
||||
* serialize them. A single try/catch around `ctx.emit` would not do the
|
||||
* last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts
|
||||
* on the first throw — so this resolves the listener callbacks via
|
||||
* `ctx.events.dispatch` and contains each call, the same guarantee
|
||||
@@ -488,7 +620,14 @@ export class SubagentService extends Service {
|
||||
: [scopeTarget(this, parent), name, acceptedInfo]
|
||||
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
|
||||
try {
|
||||
callback(acceptedInfo)
|
||||
const returned: unknown = callback(acceptedInfo)
|
||||
// Plain emits remain fire-and-forget and every callback is still invoked
|
||||
// synchronously in this loop. Observe a returned promise independently so
|
||||
// an async listener rejection is contained without serializing listeners
|
||||
// or delaying provider/run lifecycle.
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user