fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -9,11 +9,11 @@ The shared **in-process subagent run driver**. A library with no provider or imp
|
||||
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
|
||||
|
||||
1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed;
|
||||
2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists;
|
||||
2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back;
|
||||
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); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still 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 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`.
|
||||
|
||||
### `InProcessRunOptions`
|
||||
|
||||
|
||||
@@ -110,7 +110,10 @@ async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
* before the turn starts). The final `assistant/message` is the result output,
|
||||
* the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
|
||||
* factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
|
||||
* session); `cancel()` cancels the child's in-flight turn.
|
||||
* session). `cancel()` cancels a published child's in-flight turn; before
|
||||
* readiness it instead deactivates the unpublished run-owner transaction, so
|
||||
* `started` rejects, no agent/session lifecycle is published, and `result`
|
||||
* resolves `aborted`.
|
||||
*
|
||||
* Throws {@link SubagentDepthError} before creating anything when the child's
|
||||
* depth (parent depth + 1) would exceed `request.maxDepth`.
|
||||
@@ -219,9 +222,10 @@ export function startInProcessRun(
|
||||
// Install it after provider ownership succeeds but BEFORE awaiting creation,
|
||||
// so an inactive provider cannot leave an orphaned listener and abort/dispose
|
||||
// during async setup is still recorded and applied the moment a child exists.
|
||||
// `cancelled` records that a cancel was requested at all, so the pre-turn
|
||||
// cancel window — where the child clears the queued prompt before any
|
||||
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
|
||||
// `cancelled` records that a cancel was requested at all. Before readiness,
|
||||
// cancellation deactivates the unpublished run-owner transaction so the
|
||||
// factory cannot publish an agent or session. After readiness, it cancels the
|
||||
// live child. Either path settles as `aborted` (honoring the cancel contract)
|
||||
// rather than falling through to the no-turn `error` mapping.
|
||||
let cancelled = false
|
||||
// An accessor, not an inline read: `cancelled` mutates from closures (the
|
||||
@@ -230,13 +234,6 @@ export function startInProcessRun(
|
||||
const isCancelled = (): boolean => cancelled
|
||||
let child: Agent | undefined
|
||||
let handle: AgentHandle | undefined
|
||||
let disposeRequested = false
|
||||
const isDisposeRequested = (): boolean => disposeRequested
|
||||
const requestCancel = (reason: string): void => {
|
||||
cancelled = true
|
||||
child?.cancel(reason)
|
||||
}
|
||||
const onAbort = (): void => { requestCancel('subagent cancelled') }
|
||||
|
||||
// One run-owned Cordis fiber is the common ownership node. Install the
|
||||
// provider effect FIRST: a start racing an already-unloading provider fails
|
||||
@@ -250,11 +247,28 @@ export function startInProcessRun(
|
||||
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
|
||||
let ownerSetupError: unknown
|
||||
let ownerDisposing: Promise<void> | undefined
|
||||
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
|
||||
? Promise.resolve()
|
||||
: quiesceFiber(ownerFiber))
|
||||
let manualDisposeRequested = false
|
||||
const isManualDisposeRequested = (): boolean => manualDisposeRequested
|
||||
const disposeOwner = (): Promise<void> => {
|
||||
if (ownerDisposing !== undefined) return ownerDisposing
|
||||
// An already-aborted request is observed before the owner fiber is minted.
|
||||
// Do not memoize that no-op: the post-plugin cancellation check below must
|
||||
// still be able to claim and deactivate the real fiber.
|
||||
if (ownerFiber === undefined) return Promise.resolve()
|
||||
ownerDisposing = quiesceFiber(ownerFiber)
|
||||
// Pre-readiness cancellation is synchronous fire-and-forget at the public
|
||||
// `cancel()` boundary. Observe a teardown rejection here; dispose() still
|
||||
// awaits the same memoized promise and reports it to an explicit caller.
|
||||
void ownerDisposing.catch(() => undefined)
|
||||
return ownerDisposing
|
||||
}
|
||||
const requestCancel = (reason: string): void => {
|
||||
cancelled = true
|
||||
if (child === undefined) {
|
||||
if (ownerFiber !== undefined) void disposeOwner()
|
||||
return
|
||||
}
|
||||
child.cancel(reason)
|
||||
}
|
||||
const onAbort = (): void => { requestCancel('subagent cancelled') }
|
||||
const unlinkProvider = ctx.effect(() => () => {
|
||||
requestCancel('subagent provider disposed')
|
||||
return disposeOwner()
|
||||
@@ -265,6 +279,10 @@ export function startInProcessRun(
|
||||
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
|
||||
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
|
||||
}))
|
||||
// `signal.aborted` is checked before this fiber exists. Once it does, make
|
||||
// that recorded cancellation effective immediately; awaiting creation must
|
||||
// observe an inactive owner instead of reaching the publication boundary.
|
||||
if (isCancelled()) void disposeOwner()
|
||||
} catch (error: unknown) {
|
||||
ownerSetupError = error
|
||||
}
|
||||
@@ -299,8 +317,6 @@ export function startInProcessRun(
|
||||
})
|
||||
handle = created
|
||||
child = created.agent
|
||||
|
||||
if (isCancelled()) created.agent.cancel('subagent cancelled')
|
||||
return created.agent
|
||||
})()
|
||||
|
||||
@@ -322,10 +338,9 @@ export function startInProcessRun(
|
||||
// without manufacturing an unreachable runtime branch.
|
||||
liveChild = child as Agent
|
||||
} catch (error: unknown) {
|
||||
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||
if (isCancelled()) return { output: [], stopReason: 'aborted' }
|
||||
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
|
||||
}
|
||||
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||
liveChild.send(prompt)
|
||||
await liveChild.whenIdle()
|
||||
// Deliberately NO re-prompt when a structured child finishes cleanly
|
||||
@@ -348,8 +363,6 @@ export function startInProcessRun(
|
||||
async dispose(): Promise<void> {
|
||||
return (disposing ??= (async () => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
disposeRequested = true
|
||||
manualDisposeRequested = true
|
||||
requestCancel('subagent disposed during creation')
|
||||
// Removing provider ownership and disposing the common run-owner fiber
|
||||
// are the same quiescence transaction; parent disposal may already have
|
||||
|
||||
@@ -269,6 +269,38 @@ describe('startInProcessRun', () => {
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('observes detached pre-readiness teardown failure and reports it to explicit dispose', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
function inertOwner(): void {}
|
||||
const ownerFiber = ctx.plugin(inertOwner)
|
||||
await ownerFiber
|
||||
const disposeFailure = new Error('owner dispose exploded')
|
||||
const disposeSpy = vi.spyOn(ownerFiber, 'dispose').mockImplementation(() => { throw disposeFailure })
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: () => Promise.reject(new Error('creation stopped by cancellation')) },
|
||||
} as unknown as Context
|
||||
const parentWithFailingTeardown = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return ownerFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithFailingTeardown,
|
||||
}, {})
|
||||
|
||||
run.cancel('cancel before readiness')
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
await expect(run.dispose()).rejects.toBe(disposeFailure)
|
||||
disposeSpy.mockRestore()
|
||||
await ownerFiber.dispose()
|
||||
})
|
||||
|
||||
it('does not attach an abort listener when provider ownership is already inactive', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let providerCtx: Context | undefined
|
||||
|
||||
Reference in New Issue
Block a user