cleanup(subagent): hide manager-wide continuation drain

This commit is contained in:
Dudu-0223
2026-07-31 14:24:35 +08:00
committed by Tianyi Cui
parent 191c8cd640
commit f4a65a34e6
14 changed files with 41 additions and 66 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
README.md: 6fab6859e2c15fdb1ded023642cbc593e0457384
README.zh.md: 1f59807a545dcb1fafbac3f301746c7217d15f3a
README.md: cea62b90a4c5cb3c4ec74c98f4272daefe78b38e
README.zh.md: 73256ef503c23d391a2c6186c36f4ac933929c8a

View File

@@ -31,7 +31,6 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `drainContinuable()` | Close continuable admission synchronously, await every materialization that already passed admission through publication or rollback, then dispose the stable live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. |
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.

View File

@@ -31,7 +31,6 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
| `startContinuable(spec)` | 建立一个持久化可继续子 agent并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation因此调用方后续取消既不会取消已接受的轮次也不会 dispose 子 agent。

View File

@@ -201,20 +201,6 @@ export class SubagentService extends Service {
return this.requireContinuations().followup(parent, childId, content, options)
}
/**
* Close continuable admission synchronously, then dispose every live
* Activation forest child-first. A host calls this before disposing top-level
* agents so no descendant outlives the runtime that owns its teardown.
* @returns once every live Activation released its `AgentHandle`.
* @throws an aggregate error after all branches settle when any failed.
*/
async drainContinuable(): Promise<void> {
const manager = this.continuations
// Absent continuation services means nothing was ever materialized.
if (manager === undefined) return
await manager.drain()
}
/**
* Close continuable admission below exact live parent Agents, stop only their
* visible descendant Activations synchronously, then await admitted scoped

View File

@@ -122,6 +122,18 @@ function followup(
})
}
/**
* Exercise manager-wide teardown through the package-private owner rather than
* adding the irreversible operation to the public service contract.
*/
function drainManager(ctx: Context): Promise<void> {
const manager = (ctx.subagents as unknown as {
continuations?: { drain(): Promise<void> }
}).continuations
if (manager === undefined) throw new Error('expected a bound continuation manager')
return manager.drain()
}
/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
await vi.waitFor(() => {
@@ -245,7 +257,7 @@ describe('SubagentService.startContinuable', () => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
expect(ends).toEqual([])
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
await expect(drainManager(ctx)).resolves.toBeUndefined()
})
it('rejects a continuable child that would exceed the configured depth cap', async () => {
@@ -283,7 +295,7 @@ describe('SubagentService.startContinuable', () => {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
})
await ctx.subagents.drainContinuable()
await drainManager(ctx)
})
it('records a declared tool filter in the descriptor', async () => {
@@ -316,7 +328,7 @@ describe('SubagentService.startContinuable', () => {
provider: 'spawn',
toolFilter: { deny: ['noop'] },
})
await ctx.subagents.drainContinuable()
await drainManager(ctx)
})
it('cold-resumes without inventing a model route the descriptor never declared', async () => {
@@ -341,7 +353,7 @@ describe('SubagentService.startContinuable', () => {
})
expect(resumed.options.provider).toBeUndefined()
expect(resumed.options.model).toBeUndefined()
await fresh.subagents.drainContinuable()
await drainManager(fresh)
})
it('numbers the descriptor turn after an inherited fork prefix', async () => {
@@ -649,7 +661,7 @@ describe('continuable durability and teardown', () => {
const disposals: SessionId[] = []
ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) })
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
// Let the held model call observe its cancellation so quiescence can settle.
hold.resolve(undefined)
await drained
@@ -861,7 +873,7 @@ describe('continuable durability and teardown', () => {
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await ctx.subagents.drainContinuable()
await drainManager(ctx)
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
@@ -873,7 +885,7 @@ describe('continuable durability and teardown', () => {
const { ctx, parent } = await setup([])
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) })
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
@@ -890,7 +902,7 @@ describe('continuable durability and teardown', () => {
const drains: Promise<void>[] = []
ctx.on('agent/created', (child) => {
if (child === parent) return
const draining = ctx.subagents.drainContinuable().then(() => { order.push('drain') })
const draining = drainManager(ctx).then(() => { order.push('drain') })
drains.push(draining)
})
ctx.on('agent/disposed', (child) => {
@@ -926,7 +938,7 @@ describe('continuable durability and teardown', () => {
// Let the child-lock operation reach the live admission cutoff. Admission
// and inbox submission must then complete in one synchronous span.
await Promise.resolve()
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await expect(delivery).resolves.toBeTypeOf('string')
@@ -943,7 +955,7 @@ describe('continuable durability and teardown', () => {
// Accepted into the inbox, but this queued turn never opens.
await followup(ctx, parent, started.childId, message('never logged'))
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
await waitNoActivation(ctx, started.childId)
@@ -1024,7 +1036,7 @@ describe('continuable review regressions', () => {
expect(activation.accepted.size).toBe(0)
child.followup = realFollowup
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
})
@@ -1131,7 +1143,7 @@ describe('continuable review regressions', () => {
throw new Error('scoped cleanup failed')
}
await expect(ctx.subagents.drainContinuable()).rejects.toThrow()
await expect(drainManager(ctx)).rejects.toThrow()
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
// Emitting before disposal would have reported this failed epoch as success.
expect(ends[0]!.stopReason).toBe('error')
@@ -1153,7 +1165,7 @@ describe('continuable review regressions', () => {
const activation = manager.activations.get(started.childId)!
activation.observer.capture = () => { throw new Error('capture failed') }
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
@@ -1177,7 +1189,7 @@ describe('continuable review regressions', () => {
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
@@ -1196,7 +1208,7 @@ describe('continuable review regressions', () => {
// Activation must still reach settlement instead of waiting on that id.
await followup(ctx, parent, started.childId, message('discarded'))
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
@@ -1456,7 +1468,7 @@ describe('continuable errors', () => {
})
// Begin the parent Activation's teardown, then try to give it a child.
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
await expect(ctx.subagents.startContinuable(startSpec(child)))
.rejects.toMatchObject({ code: 'DRAINING' })
hold.resolve(undefined)
@@ -1490,7 +1502,7 @@ describe('continuable errors', () => {
throw new Error('grandchild reap failed')
}
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
// The other branch still released, and durable sessions survive.
@@ -1552,14 +1564,6 @@ describe('continuable errors', () => {
await waitNoActivation(ctx, started.childId)
})
it('drains without continuation services as a no-op', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SubagentService)
// No `ctx.agents`, so no manager was ever bound and nothing was materialized.
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
})
it('unloading the manager drains its live activations', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])

View File

@@ -119,10 +119,10 @@ describe('SubagentService', () => {
expect('resume' in provider).toBe(false)
})
it('treats global and scoped drains as no-ops when no manager was bound', async () => {
it('does not expose manager teardown and treats a scoped drain as a no-op when no manager was bound', async () => {
const { subagents } = await service()
// Without `ctx.agents` no manager exists, so nothing was ever materialized.
await expect(subagents.drainContinuable()).resolves.toBeUndefined()
expect('drainContinuable' in subagents).toBe(false)
await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined()
})