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

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

View File

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

View File

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