fix: complete scoped lifecycle simplification

This commit is contained in:
Tianyi Cui
2026-07-12 23:15:07 +08:00
parent ed5304fb6d
commit 738054562d
16 changed files with 261 additions and 50 deletions

View File

@@ -468,13 +468,13 @@ export class WorkerRun implements WorkflowRun {
.catch((error: unknown) => {
this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`)
})
.then(() => { this.finishChild(callId, record) })
.then(() => { this.finishChild(callId) })
return record.disposal
}
/** Drop an exact child record and release quiescence waiters when all work ends. */
private finishChild(callId: number, record: ChildRecord): void {
if (this.children.get(callId) === record) this.children.delete(callId)
/** Drop a child record and release quiescence waiters when all work ends. */
private finishChild(callId: number): void {
this.children.delete(callId)
this.notifyChildQuiescence()
}

View File

@@ -1049,6 +1049,67 @@ describe('dsh-workflow-workerthread', () => {
await ctx.fiber.dispose()
})
it('refuses and disposes a provider run that becomes ready after its real worker dies', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const requested = Promise.withResolvers<SubagentStartRequest>()
const ready = Promise.withResolvers<SubagentRun>()
let disposeCalls = 0
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
const provider: SubagentProvider = {
name: 'late-ready',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: (request) => {
requested.resolve(request)
// Model a backend whose independent startup boundary cannot be
// interrupted promptly. The host must still reject ownership if the
// worker dies before this promise transfers the ready run.
return ready.promise
},
}
ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'late-ready', maxConcurrentAgents: 1 })
const lifecycle: string[] = []
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
ctx.on('workflow/agent-end', () => { lifecycle.push('end') })
const handle = ctx.workflows.start({
...scripted("return await agent('pending startup')"),
parent: fakeParent(),
})
const request = await requested.promise
const worker = (handle as unknown as { worker: Worker }).worker
// Kill the actual Worker while provider startup is independently
// pending. Death closes admission and aborts the shared signal, but this
// deliberately uncooperative provider still fulfills afterward.
await worker.terminate()
const result = await handle.result
expect(result.stopReason).toBe('error')
expect(result.error).toContain('exit code')
expect(request.signal.aborted).toBe(true)
expect(request.signal.reason).toBe('workflow worker gone')
ready.resolve({
id: AgentId('late-ready-child'),
result: Promise.resolve({ output: [], stopReason: 'aborted' }),
dispose: () => {
disposeCalls += 1
return Promise.reject(new Error('late ready dispose failed'))
},
})
await waitFor(() => {
expect(disposeCalls).toBe(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('refused child dispose failed: Error: late ready dispose failed'))
}, 1000)
expect(lifecycle).toEqual([])
await handle.dispose()
expect(disposeCalls).toBe(1)
await ctx.fiber.dispose()
})
it('a worker that exits before settling reports an error result and reaps its children', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)