From 875c3d62d8fff24fb1a2754a99a2b2b946f4be67 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:39:55 +0800 Subject: [PATCH] fix(workflow): reap children after settled dispose --- .../workflow-workerthread/src/host.ts | 7 +++- .../tests/workflow-workerthread.spec.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 7e87c14fae..ef5fdcbfac 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -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 diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 9e37e0bbed..ec2f458d45 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -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)