fix(workflow): reap children after settled dispose

This commit is contained in:
Tianyi Cui
2026-07-12 10:39:55 +08:00
parent 48067c3a7a
commit 875c3d62d8
2 changed files with 38 additions and 1 deletions

View File

@@ -279,7 +279,12 @@ export class WorkerRun implements WorkflowRun {
void (async () => {
this.detachInputSignal()
this.cancel('workflow disposed')
for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run)
// cancel() deliberately becomes a no-op after terminal settlement, but
// disposal still owns every registered child. Reap independently so an
// already-settled workflow cannot wait on child quiescence before it has
// started the surviving children's disposals. On an unsettled run this
// joins the cancel path through the per-call cancellation/disposal gates.
this.reapChildren('workflow disposed')
await Promise.race([
(async () => {
await this.result

View File

@@ -755,6 +755,38 @@ 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 () => {
const { ctx, parent, provider } = await setup({
manual: true,
config: { provider: 'stub', disposeGraceMs: 30_000 },
})
const handle = ctx.workflows.start({
...scripted("agent('stray')\nawait new Promise(() => {})"),
parent,
})
await waitFor(() => { expect(provider.runs).toHaveLength(1) })
// Claim the host result while the real worker remains wedged, so it can
// send neither ChildDispose nor an exit. This leaves the accepted child
// in the host registry when public disposal begins.
const worker = (handle as unknown as { worker: Worker }).worker
worker.emit('message', {
type: WorkerToHostType.Result,
result: { value: 'synthetic completion', stopReason: 'completed', agentsStarted: 1 },
})
await expect(handle.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(provider.runs[0]!.disposed).toBe(false)
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()
})
it('the settle-reap fires the request signal too: a provider honoring ONLY the signal winds its stray down promptly', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)