refactor(subagent): unify async readiness and cancellation

This commit is contained in:
Tianyi Cui
2026-07-12 22:41:59 +08:00
parent 02ca71db57
commit bb3f6bd736
49 changed files with 1350 additions and 4147 deletions

View File

@@ -49,7 +49,7 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => {
])
const childIds: string[] = []
ctx.on('workflow/agent-start', (_info, agent) => {
// The workflow bridge must honor SubagentRun.started: a start observer
// The workflow bridge must await asynchronous provider start: an observer
// sees the real spawn child already published, never a reserved id.
expect(ctx.agents.get(agent.childId)).toBeDefined()
childIds.push(agent.childId)

View File

@@ -212,7 +212,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
host.close()
})
it('cancel mid-run: in-flight children get cancel RPCs, hooks throw at entry, the run reports cancelled', async () => {
it('cancel mid-run: hooks throw at entry and the run reports cancelled', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init(`
phase('before')
@@ -232,7 +232,6 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('stop everything')
expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId)
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
// No post-cancel narration left the runtime (the hooks threw at entry).
expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['before'])
@@ -281,36 +280,6 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
}
})
it('queues Result before settlement-only cancellation of a ready stray', async () => {
const host = fakeHost({ manual: true })
const session = runWorkerSession(host.port, init(`
agent('ready stray')
return await agent('gate')
`))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart)).toHaveLength(2) })
const starts = host.ofType(WorkerToHostType.ChildStart)
const stray = starts.find(message => message.request.prompt === 'ready stray')!
const gate = starts.find(message => message.request.prompt === 'gate')!
host.send({ type: HostToWorkerType.ChildStarted, callId: stray.callId, childId: 'stray-child' })
host.send({ type: HostToWorkerType.ChildStarted, callId: gate.callId, childId: 'gate-child' })
host.send({ type: HostToWorkerType.ChildSettled, callId: gate.callId, result: text('gate completed') })
const result = await host.result()
await session
await vi.waitFor(() => {
expect(host.ofType(WorkerToHostType.ChildCancel).map(message => message.callId)).toContain(stray.callId)
})
expect(result).toMatchObject({ value: 'gate completed', stopReason: 'completed', agentsStarted: 2 })
const resultIndex = host.messages.findIndex(message => message.type === WorkerToHostType.Result)
const strayCancelIndex = host.messages.findIndex(message =>
message.type === WorkerToHostType.ChildCancel && message.callId === stray.callId)
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(strayCancelIndex).toBeGreaterThan(resultIndex)
host.send({ type: HostToWorkerType.ChildSettled, callId: stray.callId, result: { output: [], stopReason: 'aborted' } })
host.close()
})
it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => {
const host = fakeHost()
await runWorkerSession(host.port, init('return ((('))
@@ -463,7 +432,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
host.close()
})
it('a cancel landing DURING the start round-trip winds the fresh child down (cancel + dispose) and dies cancelled', async () => {
it('a cancel landing DURING the start round-trip disposes the fresh child and dies cancelled', async () => {
const host = fakeHost({ manual: true })
void runWorkerSession(host.port, init("return await agent('p')"))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
@@ -477,7 +446,6 @@ describe('runWorkerSession over an in-process MessageChannel', () => {
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
await vi.waitFor(() => {
expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId)
expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId)
})
// The child never became an agent-start: it was wound down pre-lifecycle.

View File

@@ -48,9 +48,9 @@ const ESCAPE = "globalThis.constructor.constructor('return process')()"
/** One controllable child run: the test (or auto mode) settles it. */
interface ControlledRun {
request: SubagentStartRequest
/** Fulfill the provider publication/readiness boundary. */
/** Fulfill the provider's async start with a ready child. */
publish(): void
/** Reject the provider publication/readiness boundary. */
/** Reject the provider's async start before ownership transfer. */
rejectStart(error: unknown): void
settle(result: SubagentResult): void
rejectResult(error: unknown): void
@@ -75,17 +75,19 @@ class StubProvider implements SubagentProvider {
private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult,
private readonly disposeDelayMs = 0,
private readonly deferStart = false,
private readonly onCancel?: (reason: string | undefined, index: number) => void,
private readonly onAbortString?: (reason: string | undefined, index: number) => void,
private readonly onSignalAbort?: (reason: unknown, index: number) => void,
) {}
start(request: SubagentStartRequest): SubagentRun {
const readiness = Promise.withResolvers<undefined>()
async start(request: SubagentStartRequest): Promise<SubagentRun> {
const startGate = Promise.withResolvers<undefined>()
const terminal = Promise.withResolvers<SubagentResult>()
terminal.promise.catch(() => { /* provider owns early settlement until publication */ })
let published = false
const controlled: ControlledRun = {
request,
publish: () => { readiness.resolve(undefined) },
rejectStart: (error) => { readiness.reject(error) },
publish: () => { published = true; startGate.resolve(undefined) },
rejectStart: (error) => { startGate.reject(error) },
settle: (result) => { terminal.resolve(result) },
rejectResult: (error) => { terminal.reject(error) },
cancelled: undefined,
@@ -94,24 +96,29 @@ class StubProvider implements SubagentProvider {
}
this.runs.push(controlled)
const index = this.runs.length - 1
request.signal?.addEventListener('abort', () => {
this.onSignalAbort?.(request.signal?.reason, index)
terminal.resolve({ output: [], stopReason: 'aborted' })
request.signal.addEventListener('abort', () => {
controlled.cancelled = String(request.signal.reason ?? 'cancelled')
this.onAbortString?.(String(request.signal.reason ?? 'cancelled'), index)
this.onSignalAbort?.(request.signal.reason, index)
if (published) terminal.resolve({ output: [], stopReason: 'aborted' })
else startGate.reject(new Error('child start aborted before publication'))
}, { once: true })
if (!this.deferStart) readiness.resolve(undefined)
if (!this.deferStart) controlled.publish()
if (this.reply) {
const reply = this.reply
queueMicrotask(() => { terminal.resolve(reply(request, index)) })
}
try {
await startGate.promise
} catch (error: unknown) {
controlled.disposeCalls += 1
controlled.disposed = true
throw error
}
if (request.signal.aborted) throw new Error('child start aborted before publication')
return {
id: AgentId(`stub-child-${index}`),
started: readiness.promise,
result: terminal.promise,
cancel: (reason?: string) => {
controlled.cancelled = reason ?? 'cancelled'
this.onCancel?.(reason, index)
terminal.resolve({ output: [], stopReason: 'aborted' })
},
dispose: () => {
controlled.disposeCalls += 1
if (this.disposeDelayMs === 0) {
@@ -140,7 +147,7 @@ interface SetupOptions {
manual?: boolean
disposeDelayMs?: number
deferStart?: boolean
onChildCancel?: (reason: string | undefined, index: number) => void
onChildAbortString?: (reason: string | undefined, index: number) => void
onChildSignalAbort?: (reason: unknown, index: number) => void
}
@@ -152,7 +159,7 @@ async function setup(options?: SetupOptions) {
options?.manual ? undefined : options?.reply ?? (() => text('stub reply')),
options?.disposeDelayMs ?? 0,
options?.deferStart ?? false,
options?.onChildCancel,
options?.onChildAbortString,
options?.onChildSignalAbort,
)
ctx.subagents.registerProvider(provider)
@@ -243,7 +250,7 @@ describe('dsh-workflow-workerthread', () => {
expect(result.error).toContain('agent() could not start a child')
})
it('waits for child readiness before announcing it and snapshots a result that settled early', async () => {
it('waits for async provider start before announcing a result that settled early', async () => {
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
const order: string[] = []
ctx.on('workflow/agent-start', (_info, agent) => { order.push(`start:${agent.seq}`) })
@@ -254,10 +261,8 @@ describe('dsh-workflow-workerthread', () => {
await waitFor(() => { expect(provider.runs.length).toBe(1) })
const early = text('accepted value')
provider.runs[0]!.settle(early)
// Let the host observe + snapshot result while readiness remains pending.
// The provider still owns this early result while start is pending.
await new Promise(resolve => setTimeout(resolve, 0))
const earlyText = early.output[0] as { type: 'text'; text: string }
earlyText.text = 'mutated after settlement'
expect(order).toEqual([])
provider.runs[0]!.publish()
@@ -268,7 +273,7 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs[0]!.disposeCalls).toBe(1)
})
it('observes an early result rejection but sends ChildStarted before ChildFailed after readiness', async () => {
it('observes an early result rejection but sends ChildStarted before ChildFailed after start fulfills', async () => {
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
const lifecycle: string[] = []
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
@@ -299,7 +304,7 @@ describe('dsh-workflow-workerthread', () => {
await handle.dispose()
})
it('classifies readiness rejection as AGENT_START, drops an early result, and emits no false lifecycle pair', async () => {
it('classifies provider start rejection as AGENT_START, drops an early result, and emits no false lifecycle pair', async () => {
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
const lifecycle: string[] = []
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
@@ -311,7 +316,7 @@ describe('dsh-workflow-workerthread', () => {
})
await waitFor(() => { expect(provider.runs.length).toBe(1) })
// ACP-style failure can settle result(error) before its session/publication
// boundary rejects. Readiness must dominate that buffered child outcome.
// boundary rejects. Start rejection must dominate that buffered child outcome.
provider.runs[0]!.settle({ output: [], stopReason: 'error' })
await new Promise(resolve => setTimeout(resolve, 0))
provider.runs[0]!.rejectStart(new Error('publication rolled back'))
@@ -328,7 +333,7 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs[0]!.disposeCalls).toBe(1)
})
it('cancels and disposes a readiness-pending child once without publishing workflow lifecycle', async () => {
it('aborts a pending provider start once without publishing workflow lifecycle', async () => {
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true, config: { disposeGraceMs: 500 } })
const lifecycle: string[] = []
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
@@ -342,7 +347,7 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs[0]!.disposed).toBe(true)
})
// Ensure the host-driven disposal removed the registry entry before the
// late readiness rejection; its callback must not invoke dispose again.
// late start rejection; its callback must not invoke dispose again.
await new Promise(resolve => setTimeout(resolve, 0))
provider.runs[0]!.rejectStart(new Error('cancelled before publication'))
@@ -360,11 +365,9 @@ describe('dsh-workflow-workerthread', () => {
name: 'rejecting',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('reject-child'),
started: Promise.resolve(),
result: Promise.reject(new Error('backend exploded')),
cancel: () => { /* nothing in flight */ },
dispose: () => Promise.resolve(),
}),
}
@@ -385,24 +388,20 @@ describe('dsh-workflow-workerthread', () => {
try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }
`))
expect(result.value).toMatchObject({ code: 'AGENT_RESULT' })
expect((result.value as { message: string }).message).toContain('subagent result must be losslessly JSON-serializable')
expect((result.value as { message: string }).message).toContain('workflow child result could not cross the worker boundary')
})
it('contains a non-JSON result even if the injected subagent service violates its normalization contract', async () => {
// SubagentService normally rejects this before the workflow sees it. Stub
// the injected seam itself so the host's defensive worker-boundary guard
// remains independently covered rather than becoming dead, untested code.
// The real worker boundary must reject a non-JSON same-process result.
const { ctx, parent } = await setup()
const invalid = {
output: [],
structured: () => { /* deliberately outside lossless JSON */ },
stopReason: 'completed',
} as unknown as SubagentResult
const start = vi.spyOn(ctx.subagents, 'start').mockReturnValue({
const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({
id: AgentId('raw-invalid-child'),
started: Promise.resolve(),
result: Promise.resolve(invalid),
cancel: () => { /* already settled */ },
dispose: () => Promise.resolve(),
})
@@ -416,31 +415,6 @@ describe('dsh-workflow-workerthread', () => {
.toContain('workflow child result could not cross the worker boundary')
})
it('reads each resolved child-result field once before crossing the worker boundary', async () => {
let structuredReads = 0
class DriftedStructured { readonly value = 'drifted' }
const { ctx, parent } = await setup({
reply: () => ({
output: [],
get structured() {
structuredReads += 1
return structuredReads === 1 ? { value: 'accepted' } : new DriftedStructured()
},
stopReason: 'completed',
}),
})
const result = await run(ctx, parent, scripted(`
const found = await agent('p', {
schema: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }
})
return found.value
`))
expect(result.value).toBe('accepted')
expect(structuredReads).toBe(1)
})
it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
@@ -448,9 +422,8 @@ describe('dsh-workflow-workerthread', () => {
name: 'bad-dispose',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('bad-dispose-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
cancel: () => { /* settled already */ },
dispose: () => { throw new Error('dispose exploded') },
@@ -470,9 +443,8 @@ describe('dsh-workflow-workerthread', () => {
name: 'coercion-trap-dispose',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('trap-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
cancel: () => { /* settled already */ },
// The rejection VALUE's own coercion throws: a warn built with bare
@@ -755,7 +727,7 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs[0]!.disposed).toBe(true)
})
it('dispose() reaps a registered stray after result settlement even when the worker cannot relay disposal', async () => {
it('result settlement reaps a registered stray even when the worker cannot relay disposal', async () => {
const { ctx, parent, provider } = await setup({
manual: true,
config: { provider: 'stub', disposeGraceMs: 30_000 },
@@ -775,13 +747,9 @@ describe('dsh-workflow-workerthread', () => {
result: { value: 'synthetic completion', stopReason: 'completed', agentsStarted: 1 },
})
await expect(handle.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(provider.runs[0]!.disposed).toBe(false)
await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000)
const disposal = handle.dispose()
// A 30-second grace makes this assertion mutation-sensitive: without the
// settled-path host reap, no worker message can start child disposal and
// this bounded wait fails long before the grace fallback.
await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000)
await disposal
expect(provider.runs[0]!.disposeCalls).toBe(1)
await ctx.fiber.dispose()
@@ -795,21 +763,16 @@ describe('dsh-workflow-workerthread', () => {
name: 'signal-only',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
let settle!: (result: SubagentResult) => void
const result = new Promise<SubagentResult>((resolve) => { settle = resolve })
request.signal?.addEventListener('abort', () => {
aborted.push(String(request.signal?.reason))
request.signal.addEventListener('abort', () => {
aborted.push(String(request.signal.reason))
settle({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id: AgentId('signal-only-child'),
started: Promise.resolve(),
result,
// The seam leaves a provider free to honor EITHER cancel channel;
// this one deliberately ignores run.cancel() — only the request
// signal can wind it down.
cancel: () => { /* signal-only by design */ },
dispose: () => Promise.resolve(),
}
},
@@ -834,7 +797,7 @@ describe('dsh-workflow-workerthread', () => {
await handle.dispose()
})
it('the settle-reap explicitly cancels a readiness-pending stray before workflow/end', async () => {
it('the settle-reap aborts a pending provider start before workflow/end', async () => {
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
const childLifecycle: string[] = []
let cancellationAtWorkflowEnd: string | undefined
@@ -845,7 +808,7 @@ describe('dsh-workflow-workerthread', () => {
})
const handle = ctx.workflows.start({
...scripted(`
agent('readiness-pending stray')
agent('start-pending stray')
return 'done'
`),
parent,
@@ -864,129 +827,11 @@ describe('dsh-workflow-workerthread', () => {
expect(provider.runs[0]!.disposeCalls).toBe(1)
})
it('post-result child cleanup cannot reentrantly rewrite a completed workflow as cancelled', async () => {
let cancelCallbacks = 0
let signalCallbacks = 0
const { ctx, parent, provider } = await setup({
manual: true,
deferStart: true,
onChildCancel: () => {
cancelCallbacks += 1
// The first callback is host cleanup for the already-arrived Result.
// Reentering cancel() here is later than that message and must not
// retroactively win the result race. Its nested child cancel is
// intentionally ignored to keep the adversarial callback finite.
if (cancelCallbacks === 1) handle.cancel('reentrant child cleanup')
},
onChildSignalAbort: () => {
signalCallbacks += 1
handle.cancel('reentrant signal cleanup')
},
})
const handle = ctx.workflows.start({
...scripted(`
agent('readiness-pending stray')
return 'completed first'
`),
parent,
})
const result = await handle.result
expect(result).toMatchObject({ value: 'completed first', stopReason: 'completed', agentsStarted: 1 })
expect(signalCallbacks).toBe(1)
expect(cancelCallbacks).toBe(1)
// Readiness crossing after Result is a terminal-admission refusal: no
// ChildStarted/lifecycle publication, and host-owned disposal begins.
provider.runs[0]!.publish()
await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000)
expect(cancelCallbacks).toBe(1)
await handle.dispose()
await ctx.fiber.dispose()
})
it('late readiness after completed disposal cannot cancel or dispose the retired child twice', async () => {
let explicitCancels = 0
const lifecycle: string[] = []
const { ctx, parent, provider } = await setup({
manual: true,
deferStart: true,
onChildCancel: () => { explicitCancels += 1 },
})
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
ctx.on('workflow/agent-end', () => { lifecycle.push('end') })
const handle = ctx.workflows.start({
...scripted("agent('retired readiness')\nreturn 'done'"),
parent,
})
await expect(handle.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(explicitCancels).toBe(1)
await handle.dispose()
expect(provider.runs[0]!.disposed).toBe(true)
expect(provider.runs[0]!.disposeCalls).toBe(1)
// The Promise may still fulfill after its run left every host ledger.
// Refusal replies once but must not recreate the deleted cancel gate.
provider.runs[0]!.publish()
await Promise.resolve()
await Promise.resolve()
expect(explicitCancels).toBe(1)
expect(provider.runs[0]!.disposeCalls).toBe(1)
expect(lifecycle).toEqual([])
await ctx.fiber.dispose()
})
it.each([
['synchronous', (cancel: () => void) => { cancel() }],
['microtask', (cancel: () => void) => { queueMicrotask(cancel) }],
])('a ready stray %s cleanup callback cannot beat the earlier worker result claim', async (_mode, reenter) => {
let reentered = false
const explicitCancels = new Map<number, number>()
const { ctx, parent, provider } = await setup({
manual: true,
onChildCancel: (_reason, index) => {
explicitCancels.set(index, (explicitCancels.get(index) ?? 0) + 1)
if (index !== 0 || reentered) return
reentered = true
reenter(() => { handle.cancel('reentered from child cleanup') })
},
})
const handle = ctx.workflows.start({
...scripted(`
agent('ready stray')
return await agent('gate')
`),
parent,
})
const cancelChildSpy = vi.spyOn(handle as unknown as {
cancelChild(callId: number, run: SubagentRun, reason?: string): void
}, 'cancelChild')
await waitFor(() => { expect(provider.runs).toHaveLength(2) })
provider.runs[1]!.settle(text('gate completed'))
const result = await handle.result
await Promise.resolve()
expect(result).toMatchObject({ value: 'gate completed', stopReason: 'completed', agentsStarted: 2 })
expect(reentered).toBe(true)
// The host claim and worker's FIFO-later ChildCancel both reach the
// routing gate, but the provider callback is not an idempotent seam:
// invoke it exactly once for this callId.
await waitFor(() => {
expect(cancelChildSpy.mock.calls.filter(([callId]) => callId === 1)).toHaveLength(2)
}, 1000)
expect(explicitCancels.get(0)).toBe(1)
cancelChildSpy.mockRestore()
await handle.dispose()
await ctx.fiber.dispose()
})
it('a duplicate Result after the terminal claim cannot repeat cleanup or rewrite the outcome', async () => {
let explicitCancels = 0
let signalAborts = 0
const { ctx, parent, provider } = await setup({
manual: true,
onChildCancel: (_reason, index) => { if (index === 0) explicitCancels += 1 },
onChildAbortString: (_reason, index) => { if (index === 0) signalAborts += 1 },
})
const handle = ctx.workflows.start({
...scripted("agent('stray')\nawait new Promise(() => {})"),
@@ -1005,266 +850,9 @@ describe('dsh-workflow-workerthread', () => {
})
await expect(handle.result).resolves.toMatchObject({ value: 'first', stopReason: 'completed' })
expect(explicitCancels).toBe(1)
expect(signalAborts).toBe(1)
await handle.dispose()
expect(explicitCancels).toBe(1)
await ctx.fiber.dispose()
})
it('contains a throwing child cancel and still settles after cancelling peer strays', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
let starts = 0
const cancelled: string[] = []
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const provider: SubagentProvider = {
name: 'throwing-cancel',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => {
const index = starts++
return {
id: AgentId(`throwing-cancel-${index}`),
started: new Promise(() => { /* readiness stays pending */ }),
result: new Promise(() => { /* cancellation callback owns settlement */ }),
cancel: (reason?: string) => {
if (index === 0) throw new Error('cancel callback broke')
cancelled.push(`${index}:${reason ?? 'cancelled'}`)
},
dispose: () => Promise.resolve(),
}
},
}
ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'throwing-cancel', maxConcurrentAgents: 2 })
const handle = ctx.workflows.start({
...scripted(`
agent('first stray')
agent('second stray')
return 'done'
`),
parent: fakeParent(),
})
const result = await handle.result
expect(result.stopReason).toBe('completed')
expect(starts).toBe(2)
expect(cancelled).toContain('1:workflow settled')
expect(warnings.some(message => message.includes('cancel callback broke'))).toBe(true)
await handle.dispose()
})
it("cancel() drives each child's explicit cancel() host-side: a wedged worker cannot delay it", async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
let starts = 0
const cancelled: string[] = []
const provider: SubagentProvider = {
name: 'cancel-only',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => {
starts += 1
return {
id: AgentId('cancel-only-child'),
started: Promise.resolve(),
result: new Promise(() => { /* only cancel() ends this child */ }),
// Deliberately ignores the request signal — the seam leaves a
// provider free to honor ONLY the explicit cancel() channel.
cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') },
dispose: () => Promise.resolve(),
}
},
}
ctx.subagents.registerProvider(provider)
// A deliberately huge grace: if only the grace/terminate reap could
// reach this child, the assertion below would time out first.
await ctx.plugin(WorkerWorkflowEngine, { provider: 'cancel-only', maxConcurrentAgents: 2, disposeGraceMs: 30_000 })
const handle = ctx.workflows.start({
// The stray child's start RPC reaches the host, then the script wedges
// its own worker in a synchronous spin: the worker cannot process the
// Cancel message, so it can relay NO ChildCancel RPC — only the host's
// own children loop can deliver the explicit cancel in time. The
// microtask yields let the agent() continuation POST its child-start
// before the spin seizes the worker's loop (the posted message needs
// no further worker-loop turns to reach the host).
...scripted(`
agent('wedged child')
for (let i = 0; i < 20; i++) await null
const end = Date.now() + 1500
while (Date.now() < end) {}
return 'raced'
`),
parent: fakeParent(),
})
await waitFor(() => { expect(starts).toBe(1) })
handle.cancel('stop now')
await waitFor(() => { expect(cancelled).toEqual(['stop now']) }, 800)
// The wedged worker's own completion loses to the in-flight cancel.
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
await handle.dispose()
}, 15_000)
it.each(['fulfills', 'rejects'] as const)('provider.start() reentrant cancellation refuses the run when readiness later %s', async (readinessOutcome) => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const readiness = Promise.withResolvers<undefined>()
let starts = 0
let explicitCancels = 0
let disposals = 0
let sawAbortedSignal = false
const lifecycle: string[] = []
const provider: SubagentProvider = {
name: 'start-reentry',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: (request) => {
starts += 1
// This arbitrary provider callback runs before onChildStart can put
// the returned run in its registry. Cancellation must be rechecked
// after return instead of trusting the pre-start admission check.
handle.cancel('provider start reentered cancellation')
sawAbortedSignal = request.signal?.aborted === true
return {
id: AgentId('start-reentry-child'),
started: readiness.promise,
result: new Promise(() => { /* refusal owns teardown */ }),
// Deliberately honors only the explicit channel. It must still be
// reached promptly even though the first host fanout saw no run.
cancel: () => { explicitCancels += 1 },
dispose: () => {
disposals += 1
return Promise.resolve()
},
}
},
}
ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, {
provider: 'start-reentry',
maxConcurrentAgents: 2,
disposeGraceMs: 30_000,
})
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
ctx.on('workflow/agent-end', () => { lifecycle.push('end') })
const handle = ctx.workflows.start({
...scripted("await agent('reentrant provider')\nreturn 'unreachable'"),
parent: fakeParent(),
})
await waitFor(() => { expect(starts).toBe(1) })
// Either later readiness settlement must not answer the already-refused
// start again or emit a workflow lifecycle pair.
if (readinessOutcome === 'fulfills') readiness.resolve(undefined)
else readiness.reject(new Error('late readiness rejection after refusal'))
let result: WorkflowResult | undefined
void handle.result.then((value) => { result = value })
await waitFor(() => {
expect(explicitCancels).toBe(1)
expect(disposals).toBe(1)
expect(result?.stopReason).toBe('cancelled')
}, 1000)
expect(sawAbortedSignal).toBe(true)
expect(lifecycle).toEqual([])
await handle.dispose()
expect(explicitCancels).toBe(1)
expect(disposals).toBe(1)
await ctx.fiber.dispose()
})
it('claims workflow and child disposal before a raw provider disposer reenters handle.dispose()', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const terminal = Promise.withResolvers<SubagentResult>()
const observed: { reentrant?: Promise<void> } = {}
let starts = 0
let rawDisposeCalls = 0
const provider: SubagentProvider = {
name: 'dispose-reentry',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => {
starts += 1
return {
id: AgentId('dispose-reentry-child'),
started: Promise.resolve(),
result: terminal.promise,
cancel: () => { terminal.resolve({ output: [], stopReason: 'aborted' }) },
dispose: () => {
rawDisposeCalls += 1
observed.reentrant = handle.dispose()
return Promise.resolve()
},
}
},
}
ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'dispose-reentry', maxConcurrentAgents: 2 })
const handle = ctx.workflows.start({
...scripted("await agent('live child')\nreturn 'unreachable'"),
parent: fakeParent(),
})
await waitFor(() => { expect(starts).toBe(1) })
const disposal = handle.dispose()
expect(observed.reentrant).toBe(disposal)
await disposal
expect(rawDisposeCalls).toBe(1)
await expect(handle.result).resolves.toMatchObject({ stopReason: 'cancelled' })
await ctx.fiber.dispose()
})
it('claims worker-originated child disposal before its raw disposer reenters holder disposal', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const terminal = Promise.withResolvers<SubagentResult>()
const observed: { reentrant?: Promise<void> } = {}
let starts = 0
let rawDisposeCalls = 0
const provider: SubagentProvider = {
name: 'child-dispose-reentry',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => {
starts += 1
return {
id: AgentId('child-dispose-reentry-child'),
started: Promise.resolve(),
result: terminal.promise,
cancel: () => { terminal.resolve({ output: [], stopReason: 'aborted' }) },
dispose: () => {
rawDisposeCalls += 1
// This begins holder disposal from the worker's ChildDispose
// callback, before any public handle.dispose() call exists.
observed.reentrant = handle.dispose()
return Promise.resolve()
},
}
},
}
ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'child-dispose-reentry', maxConcurrentAgents: 2 })
const handle = ctx.workflows.start({
...scripted("return await agent('settling child')"),
parent: fakeParent(),
})
const finishChildSpy = vi.spyOn(handle as unknown as {
finishChild(callId: number): void
}, 'finishChild')
await waitFor(() => { expect(starts).toBe(1) })
terminal.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' })
await waitFor(() => { expect(observed.reentrant).toBeDefined() }, 1000)
await observed.reentrant
expect(rawDisposeCalls).toBe(1)
expect(finishChildSpy.mock.calls.filter(([callId]) => callId === 1)).toHaveLength(1)
finishChildSpy.mockRestore()
await expect(handle.result).resolves.toMatchObject({ stopReason: 'cancelled' })
expect(signalAborts).toBe(1)
await ctx.fiber.dispose()
})
@@ -1466,29 +1054,21 @@ describe('dsh-workflow-workerthread', () => {
await ctx.plugin(SubagentService)
// The child's dispose() REJECTS on top of the worker death: the reap
// must contain it (warn, not crash) while still emptying the registry.
const cancelled: string[] = []
const signalAborts: unknown[] = []
const provider: SubagentProvider = {
name: 'doomed',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: (request) => {
request.signal?.addEventListener('abort', () => {
signalAborts.push(request.signal?.reason)
start: async (request) => {
request.signal.addEventListener('abort', () => {
signalAborts.push(request.signal.reason)
// The death claim precedes the shared-signal fanout. This
// synchronous callback cannot turn death into cancellation.
handle.cancel('reentered from worker-death signal cleanup')
}, { once: true })
return {
id: AgentId('doomed-child'),
started: Promise.resolve(),
result: new Promise(() => { /* never settles; the reap is the teardown */ }),
cancel: (reason?: string) => {
cancelled.push(reason ?? 'cancelled')
// Exercise the later microtask case too: terminal ownership
// remains closed after the death callback returns.
queueMicrotask(() => { handle.cancel('reentered from worker-death child cleanup') })
},
dispose: () => Promise.reject(new Error('dispose exploded during reap')),
}
},
@@ -1521,7 +1101,6 @@ describe('dsh-workflow-workerthread', () => {
// cold-start race; tight explicit bound (see the helper's doc comment).
await waitFor(() => {
expect(signalAborts).toEqual(['workflow worker gone'])
expect(cancelled).toEqual(['workflow worker gone'])
}, 1000)
await Promise.resolve()
expect(result.stopReason).toBe('error')
@@ -1647,14 +1226,14 @@ describe('dsh-workflow-workerthread', () => {
})
describe('service surface', () => {
it('run ids are unique per start; the run handle and event payloads hold SEPARATE meta clones', async () => {
it('run ids are unique and lifecycle meta is the run\'s borrowed immutable value', async () => {
const { ctx, parent } = await setup()
let eventMeta: WorkflowRunInfo | undefined
ctx.on('workflow/start', (info) => { eventMeta = info })
const first = ctx.workflows.start({ ...scripted('return 1'), parent })
const second = ctx.workflows.start({ ...scripted('return 2'), parent })
expect(first.id).not.toBe(second.id)
eventMeta!.meta.name = 'corrupted'
expect(eventMeta!.meta).toBe(second.meta)
expect(second.meta.name).toBe('test-flow')
await Promise.all([first.result, second.result])
await first.dispose()