fix(subagent): close final continuation races

This commit is contained in:
Dudu-0223
2026-07-30 21:51:31 +08:00
committed by Tianyi Cui
parent a91b20f6be
commit 8f3613c4b7
9 changed files with 162 additions and 50 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 .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
2026-07-28-continuable-subagent-conversations.md: e0119975d5f815886d671959efdc3028a5929f46
2026-07-28-continuable-subagent-conversations.zh.md: fdf34d68260f70ef34682f0150a43aa1539dc767
2026-07-28-continuable-subagent-conversations.md: ec194314d88958becde4672a08570fd1facacb3c
2026-07-28-continuable-subagent-conversations.zh.md: 7c776a4e34e16c1cfd56c8964f25b4f4176001dd

View File

@@ -44,7 +44,7 @@ Cold resume does not dispatch through a subagent provider. The continuation mana
`SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent.
`ctx.subagents.followup(parent, childId, content, { source, signal })` remains the sole continuation-message operation. The exact live parent Agent authorizes delivery; `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation.
`ctx.subagents.followup(parent, childId, content, { source, signal })` remains the sole continuation-message operation. The exact live parent Agent authorizes delivery; cold resume checks that authority before reconstruction and every path checks it again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation.
For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `MessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child.
@@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha
Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add.
Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, and Activation disposal in the same synchronous span as inbox submission, so teardown that begins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain.
Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission. The manager then awaits every materialization that already passed admission until it either installs a resident Activation or completes rollback, snapshots the stable live forest, disposes it child-first, and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain.
The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown.
@@ -127,7 +127,7 @@ A later host UI may expose separate **Steer** and **Follow up** actions. Host st
Authority is supplied by an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority.
This version authorizes only the durable child's direct parent. The manager checks `SessionHeader.parentSession` against the exact live parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, hosts, teams, and workflows remain rejected until a concrete consumer justifies another authority protocol.
This version authorizes only the durable child's direct parent. The manager checks `SessionHeader.parentSession` against the exact live parent Agent at the final no-await inbox-admission boundary before registering the child in that parent's `ownedChildren`; cold resume also performs an earlier check before reconstruction for fail-fast rejection. Other Agents, ancestors, hosts, teams, and workflows remain rejected until a concrete consumer justifies another authority protocol.
Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship.
@@ -185,7 +185,7 @@ The implementation pins these behaviors:
- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership through a closing transaction visible to concurrent delivery and drain; lifecycle publication failure emits no unmatched terminal edge.
- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through or requires the initial subagent provider; the persisted provider name remains lifecycle provenance after provider removal, while `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent.
- A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`.
- `followup()` accepts only the exact live direct parent; durable message provenance cannot authorize delivery.
- `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; durable message provenance cannot authorize delivery.
- Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn.
- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result.
- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup.
@@ -194,11 +194,11 @@ The implementation pins these behaviors:
- A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation.
- Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph.
- Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation.
- Host and manager teardown synchronously enter draining, reject new materialization and delivery, stop manager-owned outward notifications, dispose every snapshotted live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent.
- Host and manager teardown synchronously enter draining, reject new materialization and delivery, await every admitted materialization through publication or rollback, stop manager-owned outward notifications, dispose the stable live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent.
- This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup.
- Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee.
- No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper.
- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, provider-independent cold resume, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages.
- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, drain quiescence for a materialization caught between Agent publication and Activation registration, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages.
- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools.
- A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal.

View File

@@ -44,7 +44,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的
`SubagentProvider.start()``SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。
`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id``message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。
`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;冷恢复会在重建前检查该权限,每条路径还会在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id``message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。
对于 start 和 follow-up调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `MessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。
@@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease但这需要精确关联轮次完成而本 Task-free 提案特意不增加该机制。
顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining激活 dispose,因此在接受前开始的拆卸会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。
顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining拒绝新的创建、恢复和投递准入。管理器随后等待每个已经通过准入的物化过程,直至它安装驻留激活或完成回滚,再对稳定的在线森林创建快照,按 child-first 顺序 dispose并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining激活 dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。
activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer再注册自身的 drain disposer使逆序撤销先执行 drain、再释放该作用域如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain系统会记录单次 dispose 失败,但仍会尝试其余 handle聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
@@ -127,7 +127,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
权限来自确切的在线 Agent 工具上下文。`MessageSource``senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。
本版本只授权持久化 child 的直接 parent。管理器会根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、宿主、团队和工作流仍被拒绝直至有具体消费方证明另一种权限协议合理。
本版本只授权持久化 child 的直接 parent。管理器会在将 child 注册到该 parent 的 `ownedChildren` 之前,于最终无 await 的 inbox 准入边界根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`;冷恢复还会在重建前执行一次更早的检查,以便快速失败。其他 Agent、祖先、宿主、团队和工作流仍被拒绝直至有具体消费方证明另一种权限协议合理。
由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。
@@ -185,7 +185,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id并通过一个对并发投递和 drain 可见的关闭事务回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系;生命周期发布失败不会产生无配对的终止事件。
- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过或依赖初始 subagent 提供方;提供方移除后,持久化的提供方名称仍作为生命周期来源信息保留,且 `SubagentProvider.resume?()``SubagentProviderResumeRequest` 均不存在。
- 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun``SubagentProvider.start()``SubagentRun` 只用于 one-shot且没有 `SubagentRun.steer?()`
- `followup()` 只接受确切的在线直接 parent持久化消息来源信息不能授权投递。
- `followup()` 只接受确切的在线直接 parent,并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。
- 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO包括 child 已有开放轮次的情况。
- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target也不定义 subagent 专属路由结果。
- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up宿主和管理器拆卸则保留 child-first 全局清理。
@@ -194,11 +194,11 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
-`waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。
- 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose顶层 Agent 不加入等待图。
- 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。
- 宿主和管理器拆卸会同步进入 draining拒绝新的物化和投递停止由管理器负责的对外通知按 child-first 顺序 dispose 处于快照中的整个在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise使并发的正常结算保持幂等。
- 宿主和管理器拆卸会同步进入 draining拒绝新的物化和投递等待每个已获准的物化过程完成发布或回滚,停止由管理器负责的对外通知,按 child-first 顺序 dispose 稳定的在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise使并发的正常结算保持幂等。
- 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。
- 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。
- 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。
- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、不依赖提供方的冷恢复、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。
- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、drain 会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。
- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。
- 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。

View File

@@ -120,7 +120,8 @@ export function apply(ctx: Context, config: Config): void {
const detached = createDetachedRuns()
// Only the start edge guarantees registry access. Retain each local child
// through its paired end so stop hooks keep the session workspace after the
// handle unregisters the agent.
// handle unregisters the agent. Every retained entry relies on that paired
// end; a producer that can omit it must provide another release edge.
const subagentChildren = new Map<SubagentRunId, Agent>()
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')

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: 0e59a1ad5f256de4d6505d3d00d3790d7738a457
README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0
README.md: 06047ac87e84d50d8dc1a965c7d2499cbe58076d
README.zh.md: 206a7d6e95f61ab152829e614cfaf1d15c5bec33

View File

@@ -31,11 +31,11 @@ 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, 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. An aggregate error surfaces after every branch settles when any failed. |
| `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. |
`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.
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.

View File

@@ -31,11 +31,11 @@ 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()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 |
| `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 |
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation因此调用方后续取消既不会取消已接受的轮次也不会 dispose 子 agent。
后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。
后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。
同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。

View File

@@ -156,11 +156,22 @@ interface Activation {
poke: PromiseWithResolvers<void>
}
/** Inputs shared by fresh and resumed Activation materialization. */
interface MaterializeInputs {
childId: SessionId
provider: string
parent: Agent
/** Creation inputs; absent for a cold resume, which loads the persisted session. */
create?: { seed: readonly SessionEvent[]; meta: NonNullable<CreateAgentOptions['meta']> }
agentOptions: AgentOptions
composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
signal: AbortSignal
}
/**
* Read one Activation's current disposal transaction. This indirection exists
* because a mutable field read inside a long-lived closure narrows to its
* last-seen value, which would flatten these genuine runtime checks to
* constants.
* because TypeScript would otherwise narrow repeated reads of the mutable field
* inside a long-lived closure to constants instead of re-reading runtime state.
* @param activation - the Activation to inspect.
* @returns the in-flight or settled disposal, or `undefined` while resident.
*/
@@ -206,6 +217,8 @@ class ChildLock {
export class SubagentContinuationManager {
/** Child session id → its live Activation. Process-local, never durable. */
private activations = new Map<SessionId, Activation>()
/** Materializations admitted before drain, tracked through publication or rollback. */
private readonly materializations = new Set<Promise<void>>()
private readonly locks = new ChildLock()
/** Structural Cordis owner of every Activation handle. */
private readonly ownerCtx: Context
@@ -332,7 +345,6 @@ export class SubagentContinuationManager {
if (activation.disposal !== undefined) {
return activation.disposal.then(() => undefined, () => undefined)
}
this.authorizeLive(parent, activation)
return this.submitAdmitted(activation, content, options.source, parent, options.signal)
})
/* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
@@ -345,17 +357,20 @@ export class SubagentContinuationManager {
}
/**
* Dispose every live Activation forest child-first and await all handles.
* Sibling branches drain independently: one failure is recorded but never
* prevents the remaining handles from being attempted, and the aggregate
* rejects only after every branch settles.
* @returns once every snapshotted Activation released its handle.
* Close admission, await every already-admitted materialization through
* publication or rollback, then dispose the stable live Activation forest
* child-first. Sibling branches drain independently: one failure is recorded
* but never prevents the remaining handles from being attempted, and the
* aggregate rejects only after every branch settles.
* @returns once materialization is quiescent and every live Activation released its handle.
* @throws an aggregate error when any branch failed to release.
*/
async drain(): Promise<void> {
// Close admission synchronously before the first await, so no new creation,
// cold resume, or delivery can race the snapshot below.
// Close admission synchronously before the first await. Materializations
// already past that cutoff remain tracked until their handle is installed
// or rollback completes, producing a stable forest for the later snapshot.
this.draining = true
await Promise.all([...this.materializations])
// Snapshot roots after closing admission: a root is an Activation no live
// Activation owns, so disposing roots recurses child-first into the forest.
const owned = new Set<SessionId>()
@@ -489,16 +504,22 @@ export class SubagentContinuationManager {
* a continuation-managed parent. Rejection leaves no Activation, no handle,
* and no ownership membership.
*/
private async materialize(inputs: {
childId: SessionId
provider: string
parent: Agent
/** Creation inputs; absent for a cold resume, which loads the persisted session. */
create?: { seed: readonly SessionEvent[]; meta: NonNullable<CreateAgentOptions['meta']> }
agentOptions: AgentOptions
composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
signal: AbortSignal
}): Promise<Activation> {
private materialize(inputs: MaterializeInputs): Promise<Activation> {
this.assertAdmitting()
const settled = Promise.withResolvers<void>()
this.materializations.add(settled.promise)
return this.materializeTracked(inputs).finally(() => {
this.materializations.delete(settled.promise)
settled.resolve()
})
}
/**
* Perform one tracked materialization. The caller keeps the drain barrier
* registered until this either returns a resident Activation or finishes
* rollback.
*/
private async materializeTracked(inputs: MaterializeInputs): Promise<Activation> {
const { childId, provider, parent } = inputs
// No id pre-check here: the child lock serializes each durable child, both
// callers reach this only after confirming no Activation exists, and
@@ -673,19 +694,12 @@ export class SubagentContinuationManager {
'ACTIVATION_CLOSING',
)
}
return this.submit(activation, content, source, parent)
}
/**
* Authorize delivery to a live Activation. A parent must be the exact live
* direct parent recorded in the child's durable header.
*/
private authorizeLive(parent: Agent, activation: Activation): void {
this.authorizeLineage(
parent,
activation.childId,
activation.handle.agent.session.header.parentSession,
)
return this.submit(activation, content, source, parent)
}
/**

View File

@@ -691,6 +691,29 @@ describe('continuable durability and teardown', () => {
expect(ctx.agents.list()).toEqual([parent])
})
it('waits for a published materialization to finish rollback before drain resolves', async () => {
const { ctx, parent } = await setup([])
const order: string[] = []
const drains: Promise<void>[] = []
ctx.on('agent/created', (child) => {
if (child === parent) return
const draining = ctx.subagents.drainContinuable().then(() => { order.push('drain') })
drains.push(draining)
})
ctx.on('agent/disposed', (child) => {
if (child !== parent) order.push('disposed')
})
// `agent/created` runs after registry publication but before materialize()
// receives the handle and installs the Activation.
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
await Promise.all(drains)
expect(order).toEqual(['disposed', 'drain'])
expect(ctx.agents.list()).toEqual([parent])
})
it('admits a live follow-up before a later drain can begin disposal', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
@@ -739,6 +762,80 @@ describe('continuable durability and teardown', () => {
})
describe('continuable review regressions', () => {
it('rechecks exact parent liveness after cold-resume materialization', async () => {
const { ctx } = await setup([textResponse('first')])
const parentId = SessionId('replaceable-parent')
const originalParent = await ctx.agents.create({
sessionId: parentId,
agentOptions: { provider: 'mock', model: 'mock' },
})
const started = await ctx.subagents.startContinuable(startSpec(originalParent.agent))
await waitNoActivation(ctx, started.childId)
const manager = (ctx.subagents as unknown as {
continuations: { ownerCtx: Context }
}).continuations
const ownerAgents = manager.ownerCtx.agents
const originalResume = ownerAgents.resume.bind(ownerAgents)
const resumed = Promise.withResolvers<undefined>()
const releaseResume = Promise.withResolvers<undefined>()
const resumeSpy = vi.spyOn(ownerAgents, 'resume').mockImplementation(async (options) => {
const handle = await originalResume(options)
resumed.resolve(undefined)
await releaseResume.promise
return handle
})
const delivery = followup(
ctx,
originalParent.agent,
started.childId,
message('must not cross parent replacement'),
)
await resumed.promise
await originalParent.dispose()
const replacement = await ctx.agents.create({
sessionId: parentId,
agentOptions: { provider: 'mock', model: 'mock' },
})
releaseResume.resolve(undefined)
await expect(delivery).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
resumeSpy.mockRestore()
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(hasUserText(loaded.events, 'must not cross parent replacement')).toBe(false)
await replacement.dispose()
})
it('clears the accepted reservation when Agent.followup throws', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const manager = (ctx.subagents as unknown as {
continuations: {
activations: Map<SessionId, { accepted: Set<MessageId> }>
}
}).continuations
const activation = manager.activations.get(started.childId)!
const realFollowup = child.followup.bind(child)
child.followup = () => {
throw new Error('synthetic inbox failure')
}
await expect(followup(ctx, parent, started.childId, message('throws')))
.rejects.toThrow(/synthetic inbox failure/)
expect(activation.accepted.size).toBe(0)
child.followup = realFollowup
const drained = ctx.subagents.drainContinuable()
hold.resolve(undefined)
await drained
})
it('reports the child\'s own terminal reason, not teardown success', async () => {
// The child hits its token ceiling; teardown still succeeds.
const { ctx, parent } = await setupWith(new MockAdapter([