fix(workflow): harden terminal cleanup races
Queue worker results before settlement cleanup, claim terminal and death boundaries before provider callbacks, and close late-message admission. Make child cancellation and disposal reentrancy-safe across the workflow bridge and generic subagent wrapper, with adversarial regression coverage and RFC documentation.
This commit is contained in:
@@ -21,7 +21,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
|
||||
| `registerProvider(provider)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
|
||||
| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). |
|
||||
| `list()` | Registered provider names (insertion order). |
|
||||
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire and memoize the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
|
||||
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. The wrapper claims its shared disposal promise before invoking raw provider code, so synchronous reentry and ordinary repeats join one provider call; a raw disposer that directly returns that same reentrant wrapper promise is rejected as a cyclic provider contract instead of hanging forever. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
|
||||
|
||||
## Capabilities: two kinds, discovered two ways
|
||||
|
||||
|
||||
@@ -379,14 +379,30 @@ export class SubagentService extends Service {
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (): Promise<void> => {
|
||||
if (disposal === undefined) {
|
||||
// Claim the shared transaction before invoking provider code: a raw
|
||||
// disposer can synchronously reenter this wrapper through a reference
|
||||
// retained by its caller, and both calls must join one provider call.
|
||||
const claimed = Promise.withResolvers<undefined>()
|
||||
disposal = claimed.promise
|
||||
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, []))
|
||||
const returned: unknown = Reflect.apply(inputDispose, acceptedRun, [])
|
||||
// A raw disposer can reenter the service wrapper and directly return
|
||||
// that same shared promise. Awaiting it here would make the promise
|
||||
// depend on itself forever; reject the cyclic provider contract loud.
|
||||
if (returned === claimed.promise) {
|
||||
claimed.reject(new TypeError(`subagent provider "${name}" run dispose returned its own wrapper disposal promise`))
|
||||
return disposal
|
||||
}
|
||||
void Promise.resolve(returned).then(
|
||||
() => { claimed.resolve(undefined) },
|
||||
(error: unknown) => { claimed.reject(error) },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
disposal = Promise.reject(error instanceof Error
|
||||
claimed.reject(error instanceof Error
|
||||
? error
|
||||
: new Error('subagent provider run dispose threw a non-Error value', { cause: error }))
|
||||
}
|
||||
|
||||
@@ -150,6 +150,57 @@ describe('SubagentService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('claims wrapper disposal before a raw provider disposer can reenter it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const observed: { reentrant?: Promise<void> } = {}
|
||||
const providerDispose = vi.fn(() => {
|
||||
observed.reentrant = run.dispose()
|
||||
return Promise.resolve()
|
||||
})
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'dispose-reentry',
|
||||
capabilities: NO_CAPS,
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
id: AgentId('dispose-reentry-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
cancel() {},
|
||||
dispose: providerDispose,
|
||||
}),
|
||||
})
|
||||
const run = ctx.subagents.start('dispose-reentry', baseRequest())
|
||||
|
||||
const disposal = run.dispose()
|
||||
|
||||
expect(observed.reentrant).toBe(disposal)
|
||||
await disposal
|
||||
expect(providerDispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a raw disposer that directly returns its reentrant wrapper promise instead of hanging', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const providerDispose = vi.fn(() => run.dispose())
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'dispose-self-cycle',
|
||||
capabilities: NO_CAPS,
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
id: AgentId('dispose-self-cycle-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
cancel() {},
|
||||
dispose: providerDispose,
|
||||
}),
|
||||
})
|
||||
const run = ctx.subagents.start('dispose-self-cycle', baseRequest())
|
||||
|
||||
await expect(run.dispose()).rejects.toThrow('run dispose returned its own wrapper disposal promise')
|
||||
expect(providerDispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'a non-string name', patch: { name: 42 }, message: 'name must be a string' },
|
||||
{ label: 'null capabilities', patch: { capabilities: null }, message: 'capabilities must be an object' },
|
||||
|
||||
Reference in New Issue
Block a user