feat(subagent): add explicit child reports
This commit is contained in:
@@ -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: 5b7c0376367a942d91700739ad445cf2b7a4455a
|
||||
README.zh.md: 30c5e0b501d0cf3cac749a788bb92a0353b466e9
|
||||
README.md: 4776f45a2f4ba881c2bb8414876100dc84adc86b
|
||||
README.zh.md: a40a12a4b386c91409711b8459a6c3b1f3f37cd0
|
||||
|
||||
@@ -16,6 +16,7 @@ The family separates the stable interface from implementations and model-facing
|
||||
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. |
|
||||
|
||||
Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract.
|
||||
|
||||
@@ -31,6 +32,8 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. 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. |
|
||||
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
|
||||
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
|
||||
| `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. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode and `running`/`inactive` activity, plus per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
|
||||
@@ -87,6 +90,10 @@ Run events are scoped to the delegating parent. Every listener is independently
|
||||
|
||||
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
@@ -95,7 +102,7 @@ Continuable Activations await a best-effort final session flush without treating
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-subagent` and `dsh-tool-subagent-control`, which render provider-specific schemas and foreground, background, or follow-up results while child working context remains child-only.
|
||||
Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -104,9 +111,9 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
|
||||
- **No report delivery** — the MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup; a completed child turn leaves its output in the durable child Session until a caller inspects that transcript or submits another authorized turn.
|
||||
- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability.
|
||||
- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent cannot redirect a turn already underway; the manager stores no current-turn controller state.
|
||||
- **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn.
|
||||
- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol.
|
||||
- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically.
|
||||
- **No durable report mailbox** — reports require a live direct parent and provide acceptance identity rather than exactly-once delivery or a read receipt.
|
||||
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.
|
||||
|
||||
@@ -16,6 +16,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 |
|
||||
|
||||
多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。
|
||||
|
||||
@@ -31,6 +32,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 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`;冷恢复还要求会话持久化。 |
|
||||
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
|
||||
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
|
||||
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式和 `running`/`inactive` 活动状态,以及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
|
||||
@@ -87,6 +90,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
@@ -95,7 +102,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-subagent` 和 `dsh-tool-subagent-control` 间接产生影响;它们渲染提供方特定的 schema,以及前台、后台或后续操作结果,同时子 agent 工作上下文只留在子 agent 中。
|
||||
通过 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema,第二个负责父级延续和发现,第三个只向可继续子级作用域贡献 `report`。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -104,9 +111,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
|
||||
- **无 report 投递**:MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。
|
||||
- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。
|
||||
- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。
|
||||
- **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。
|
||||
- **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。
|
||||
- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。
|
||||
- **没有持久化的上报 mailbox**:上报需要实时直接父级,提供的是接受标识,不保证恰好一次投递,也不提供已读回执。
|
||||
- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。
|
||||
|
||||
196
packages/subagent/subagent/src/activation-setup-registry.ts
Normal file
196
packages/subagent/subagent/src/activation-setup-registry.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Internal registry of deployment capabilities composed into every continuable
|
||||
* child's unpublished creation context.
|
||||
*
|
||||
* A contribution grants a child-scoped capability without teaching the
|
||||
* continuation manager which capabilities exist. The manager owns residency;
|
||||
* this registry owns the join between plugin lifetime, unpublished setup, and
|
||||
* Activation disposal, so no installation outlives either owner and no removed
|
||||
* contribution can be installed after revocation reports completion.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/activation-setup-registry
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SubagentError } from './error.ts'
|
||||
|
||||
/**
|
||||
* One deployment capability installed into a continuable child's unpublished
|
||||
* creation context. It composes synchronously before publication and returns
|
||||
* the disposer for exactly that installation.
|
||||
* @param childCtx - the child's unpublished scoped context.
|
||||
* @returns the disposer revoking this installation.
|
||||
*/
|
||||
export type ContinuableSetupContribution = (childCtx: Context) => () => void
|
||||
|
||||
/** One contribution's live registration. */
|
||||
interface Registration {
|
||||
readonly contribution: ContinuableSetupContribution
|
||||
removed: boolean
|
||||
readonly installations: Set<Installation>
|
||||
}
|
||||
|
||||
/** One contribution installed into one child context. */
|
||||
interface Installation {
|
||||
readonly registration: Registration
|
||||
readonly childCtx: Context
|
||||
readonly dispose: () => void
|
||||
released: boolean
|
||||
/** Present until the child reaches residency. */
|
||||
transaction: TransactionState | undefined
|
||||
}
|
||||
|
||||
/** One child's provisioning batch. */
|
||||
interface TransactionState {
|
||||
readonly installations: Installation[]
|
||||
invalidated: boolean
|
||||
}
|
||||
|
||||
/** Package-private setup transaction consumed by the continuation manager. */
|
||||
export interface ActivationSetupTransaction {
|
||||
/**
|
||||
* Reject a batch invalidated by revocation before publication.
|
||||
* @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation.
|
||||
*/
|
||||
assertIntact(): void
|
||||
/** Promote this batch to resident installations. */
|
||||
commit(): void
|
||||
}
|
||||
|
||||
/** Re-read mutable removal state after a contribution may have revoked itself. */
|
||||
function isRemoved(registration: Registration): boolean {
|
||||
return registration.removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns continuable-child setup registrations, installations, rollback, child
|
||||
* cleanup, and immediate live revocation.
|
||||
*/
|
||||
export class SubagentActivationSetupRegistry {
|
||||
/** Live contributions in installation order. */
|
||||
private readonly registrations = new Set<Registration>()
|
||||
/** Child context to its live installations. */
|
||||
private readonly byChild = new Map<Context, Set<Installation>>()
|
||||
|
||||
/**
|
||||
* Register one contribution.
|
||||
* @param contribution - synchronous child-scope installer.
|
||||
* @returns an idempotent registration undo.
|
||||
* @throws after attempting every installation when any disposer fails.
|
||||
*/
|
||||
register(contribution: ContinuableSetupContribution): () => void {
|
||||
const registration: Registration = { contribution, removed: false, installations: new Set() }
|
||||
this.registrations.add(registration)
|
||||
return () => {
|
||||
if (registration.removed) return
|
||||
// Close before disposal so a snapshotted apply() cannot install after
|
||||
// revocation reports completion.
|
||||
registration.removed = true
|
||||
this.registrations.delete(registration)
|
||||
this.releaseAll([...registration.installations], 'contribution removal')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install every live contribution into one unpublished child context.
|
||||
* @param childCtx - the child's unpublished scoped context.
|
||||
* @returns the provisioning transaction.
|
||||
*/
|
||||
apply(childCtx: Context): ActivationSetupTransaction {
|
||||
const state: TransactionState = { installations: [], invalidated: false }
|
||||
try {
|
||||
for (const registration of [...this.registrations]) {
|
||||
/* v8 ignore next -- only a synchronous re-entrant revocation of an
|
||||
* already-snapshotted registration reaches this guard. */
|
||||
if (registration.removed) continue
|
||||
const installation: Installation = {
|
||||
registration,
|
||||
childCtx,
|
||||
dispose: registration.contribution(childCtx),
|
||||
released: false,
|
||||
transaction: state,
|
||||
}
|
||||
registration.installations.add(installation)
|
||||
state.installations.push(installation)
|
||||
let indexed = this.byChild.get(childCtx)
|
||||
if (indexed === undefined) {
|
||||
indexed = new Set()
|
||||
this.byChild.set(childCtx, indexed)
|
||||
}
|
||||
indexed.add(installation)
|
||||
// An installer may revoke itself before its installation record exists.
|
||||
// Dispose that escaped record and invalidate the provisioning batch.
|
||||
if (isRemoved(registration)) this.release(installation)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Keep the installer failure authoritative, but attempt every rollback.
|
||||
try {
|
||||
this.releaseAll([...state.installations], 'setup rollback')
|
||||
} catch (releaseFailure: unknown) {
|
||||
/* v8 ignore next -- requires independent installer and rollback faults. */
|
||||
void releaseFailure
|
||||
}
|
||||
throw error
|
||||
}
|
||||
childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()')
|
||||
return {
|
||||
assertIntact: () => {
|
||||
if (!state.invalidated) return
|
||||
throw new SubagentError(
|
||||
'a continuable-subagent setup contribution was revoked while this child was being built; '
|
||||
+ 'the child was not established',
|
||||
'ACTIVATION_SETUP_REVOKED',
|
||||
)
|
||||
},
|
||||
commit: () => {
|
||||
for (const installation of state.installations) installation.transaction = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Release every remaining installation owned by one disposed child scope. */
|
||||
private releaseChild(childCtx: Context): void {
|
||||
const indexed = this.byChild.get(childCtx) ?? []
|
||||
this.releaseAll([...indexed], 'child scope disposal')
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a batch completely before reporting disposer failures.
|
||||
* @param installations - records to release.
|
||||
* @param during - operation name for diagnostics.
|
||||
*/
|
||||
private releaseAll(installations: readonly Installation[], during: string): void {
|
||||
const failures: unknown[] = []
|
||||
for (const installation of installations) {
|
||||
try {
|
||||
this.release(installation)
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length === 0) return
|
||||
throw new SubagentError(
|
||||
`continuable-subagent setup ${during} failed to release ${failures.length} installation(s): `
|
||||
+ failures.map(failure => errorChain(failure)).join('; '),
|
||||
'ACTIVATION_SETUP_RELEASE_FAILED',
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop one installation from both indices and dispose it exactly once. */
|
||||
private release(installation: Installation): void {
|
||||
if (installation.released) return
|
||||
installation.released = true
|
||||
installation.registration.installations.delete(installation)
|
||||
const indexed = this.byChild.get(installation.childCtx)
|
||||
/* v8 ignore next 4 -- every live installation is indexed until this method removes it. */
|
||||
if (indexed !== undefined) {
|
||||
indexed.delete(installation)
|
||||
if (indexed.size === 0) this.byChild.delete(installation.childCtx)
|
||||
}
|
||||
if (installation.transaction !== undefined) installation.transaction.invalidated = true
|
||||
installation.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export default SubagentActivationSetupRegistry
|
||||
@@ -41,6 +41,8 @@ import { seedDescriptorTurn } from './descriptor-seed.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
|
||||
import type { ActivationObserver } from './lifecycle.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
import type SubagentActivationSetupRegistry from './activation-setup-registry.ts'
|
||||
import type { ActivationSetupTransaction } from './activation-setup-registry.ts'
|
||||
|
||||
/** Attribution for a model coordinator's follow-up to one of its children. */
|
||||
export interface CoordinatorMessageSource {
|
||||
@@ -49,12 +51,31 @@ export interface CoordinatorMessageSource {
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
/** Durable attribution for a continuable child's explicit parent report. */
|
||||
export interface SubagentReportMessageSource {
|
||||
readonly kind: 'subagent-report'
|
||||
/** Session id of the reporting child. */
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
coordinator: CoordinatorMessageSource
|
||||
'subagent-report': SubagentReportMessageSource
|
||||
}
|
||||
}
|
||||
|
||||
/** Deployment scheduling policy for accepted child reports. */
|
||||
export type SubagentReportDelivery = 'quiet' | 'wakeup'
|
||||
|
||||
/** Options for one continuable child's report to its direct parent. */
|
||||
export interface SubagentReportOptions {
|
||||
/** Already-resolved parent scheduling policy. */
|
||||
readonly delivery: SubagentReportDelivery
|
||||
/** Caller cancellation, owning authorization and admission until acceptance. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** What a caller asks for when starting a continuable background child. */
|
||||
export interface ContinuableStartSpec {
|
||||
/** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
|
||||
@@ -252,6 +273,7 @@ export class SubagentContinuationManager {
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly host: ContinuationHost,
|
||||
private readonly setupRegistry: SubagentActivationSetupRegistry,
|
||||
) {
|
||||
// Ordinary Cordis owner effects unwind in reverse registration order, which
|
||||
// cannot express the dynamic child graph. Register the private scope's
|
||||
@@ -386,6 +408,113 @@ export class SubagentContinuationManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver explicitly selected content from one resident continuable child to
|
||||
* its durable direct parent. Sender authorization, parent resolution, and
|
||||
* send acceptance share one no-await span. Reporting neither concludes the
|
||||
* child's turn nor changes its Activation lifetime.
|
||||
* @param child - exact live reporting child; this is the authority credential.
|
||||
* @param content - selected model-facing content.
|
||||
* @param options - scheduling policy and pre-acceptance cancellation.
|
||||
* @returns the stable identity of the message accepted by the parent.
|
||||
* @throws {SubagentError} when the sender is unauthorized, the parent is not
|
||||
* live, or continuation admission is closing.
|
||||
*/
|
||||
// oxlint-disable-next-line typescript/require-await -- keep rejection semantics without yielding during admission
|
||||
async reportFrom(
|
||||
child: Agent,
|
||||
content: ContentBlock[],
|
||||
options: SubagentReportOptions,
|
||||
): Promise<MessageId> {
|
||||
options.signal.throwIfAborted()
|
||||
this.assertAdmitting(child)
|
||||
const activation = this.authorizeReporter(child)
|
||||
const parent = this.resolveReportParent(child)
|
||||
return this.deliverReport(activation, parent, content, options.delivery)
|
||||
}
|
||||
|
||||
/** Authorize only the exact Agent of one resident Activation. */
|
||||
private authorizeReporter(child: Agent): Activation {
|
||||
const activation = this.activations.get(child.id)
|
||||
if (activation === undefined || activation.handle.agent !== child) {
|
||||
throw new SubagentError(
|
||||
`agent "${child.id}" is not a live continuable subagent and cannot report`,
|
||||
'UNAUTHORIZED',
|
||||
)
|
||||
}
|
||||
/* v8 ignore next 6 -- only a synchronous re-entrant disposer can open this
|
||||
* transaction between exact-agent authorization and this no-await cutoff. */
|
||||
if (activation.disposal !== undefined) {
|
||||
throw new SubagentError(
|
||||
`subagent "${child.id}" activation is being disposed; the report was not delivered`,
|
||||
'ACTIVATION_CLOSING',
|
||||
)
|
||||
}
|
||||
return activation
|
||||
}
|
||||
|
||||
/** Resolve the reporting child's live direct parent from durable lineage. */
|
||||
private resolveReportParent(child: Agent): Agent {
|
||||
const parentId = child.session.header.parentSession
|
||||
/* v8 ignore next -- every continuation-managed child has direct-parent metadata. */
|
||||
const parent = parentId === undefined ? undefined : this.ctx.agents.get(parentId)
|
||||
if (parent === undefined) {
|
||||
throw new SubagentError(
|
||||
'direct parent is not live; report was not delivered',
|
||||
'PARENT_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
/** Deliver one framed report through the selected parent scheduling preset. */
|
||||
private deliverReport(
|
||||
activation: Activation,
|
||||
parent: Agent,
|
||||
content: ContentBlock[],
|
||||
delivery: SubagentReportDelivery,
|
||||
): MessageId {
|
||||
const message = createUserMessage({
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Background subagent ${activation.childId} reported:` },
|
||||
...content,
|
||||
],
|
||||
source: {
|
||||
kind: 'subagent-report' as const,
|
||||
senderSessionId: activation.childId,
|
||||
},
|
||||
})
|
||||
const parentActivation = this.activations.get(parent.id)
|
||||
if (delivery === 'wakeup'
|
||||
&& parentActivation !== undefined
|
||||
&& parentActivation.handle.agent === parent) {
|
||||
this.admitWaking(parentActivation, message.id, () => {
|
||||
this.sendReport(parent, message, delivery)
|
||||
})
|
||||
} else {
|
||||
this.sendReport(parent, message, delivery)
|
||||
}
|
||||
return message.id
|
||||
}
|
||||
|
||||
/** Send one report while translating only the parent's own rejection. */
|
||||
private sendReport(
|
||||
parent: Agent,
|
||||
message: ReturnType<typeof createUserMessage>,
|
||||
delivery: SubagentReportDelivery,
|
||||
): void {
|
||||
try {
|
||||
if (delivery === 'wakeup') parent.followup(message)
|
||||
else parent.inject(message)
|
||||
} catch (error: unknown) {
|
||||
throw new SubagentError(
|
||||
'direct parent is not live; report was not delivered',
|
||||
'PARENT_UNAVAILABLE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close admission, await every already-admitted materialization through
|
||||
* publication or rollback, then dispose the stable live Activation forest
|
||||
@@ -671,7 +800,11 @@ export class SubagentContinuationManager {
|
||||
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
|
||||
// some other owner holds — a duplicate would reject there with rollback.
|
||||
inputs.signal.throwIfAborted()
|
||||
const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
|
||||
let setupTransaction!: ActivationSetupTransaction
|
||||
const setup = (childCtx: Context): void => {
|
||||
applyChildComposition(childCtx, inputs.composition)
|
||||
setupTransaction = this.setupRegistry.apply(childCtx)
|
||||
}
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
const { create } = inputs
|
||||
// Agent creation owns rollback before handle transfer. A rejection leaves
|
||||
@@ -709,6 +842,7 @@ export class SubagentContinuationManager {
|
||||
try {
|
||||
inputs.signal.throwIfAborted()
|
||||
this.assertAdmitting(parent)
|
||||
setupTransaction.assertIntact()
|
||||
this.acquireOwnership(parent, childId)
|
||||
// Every accepted id leaves the inbox exactly once, through dequeue or
|
||||
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
|
||||
@@ -726,8 +860,10 @@ export class SubagentContinuationManager {
|
||||
for (const item of items) activation.accepted.delete(item.message.id)
|
||||
this.wake(activation)
|
||||
})
|
||||
// Resident: publish the start edge before any turn can run, so observers
|
||||
// see this epoch before its first request.
|
||||
// Resident setup revokes live from here instead of invalidating creation.
|
||||
setupTransaction.commit()
|
||||
// Publish the start edge before any turn can run, so observers see this
|
||||
// epoch before its first request.
|
||||
observer.start(handle.agent)
|
||||
} catch (error: unknown) {
|
||||
// Listener exceptions are contained by the lifecycle emitter; a start
|
||||
@@ -803,19 +939,36 @@ export class SubagentContinuationManager {
|
||||
// establish it before the message can enter the child's inbox.
|
||||
this.acquireOwnership(parent, activation.childId)
|
||||
const message = createUserMessage({ content, source })
|
||||
// `Agent.followup()` publishes `agent/inbox/enqueue` synchronously, so its
|
||||
// observers must see this Activation as busy before the call begins.
|
||||
activation.accepted.add(message.id)
|
||||
try {
|
||||
return this.admitWaking(activation, message.id, () => {
|
||||
activation.handle.agent.followup(message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Account one waking send across a resident Activation's settlement window.
|
||||
* @param activation - Activation receiving waking inbox work.
|
||||
* @param messageId - stable identity of the message about to be sent.
|
||||
* @param send - synchronous send that publishes one enqueue occurrence.
|
||||
* @returns the accepted message id.
|
||||
*/
|
||||
private admitWaking(
|
||||
activation: Activation,
|
||||
messageId: MessageId,
|
||||
send: () => void,
|
||||
): MessageId {
|
||||
// `Agent.followup()` publishes inbox events synchronously, so observers must
|
||||
// see this Activation as busy before the call begins.
|
||||
activation.accepted.add(messageId)
|
||||
try {
|
||||
send()
|
||||
} catch (error: unknown) {
|
||||
activation.accepted.delete(message.id)
|
||||
activation.accepted.delete(messageId)
|
||||
throw error
|
||||
}
|
||||
// Accepted waking work keeps this Activation live until whenIdle() observes
|
||||
// the complete waking suffix.
|
||||
this.wake(activation)
|
||||
return message.id
|
||||
return messageId
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,7 +58,10 @@ import type {
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
SubagentFollowupOptions,
|
||||
SubagentReportOptions,
|
||||
} from './continuation.ts'
|
||||
import SubagentActivationSetupRegistry from './activation-setup-registry.ts'
|
||||
import type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
@@ -107,7 +110,11 @@ export type {
|
||||
ContinuableStartSpec,
|
||||
CoordinatorMessageSource,
|
||||
SubagentFollowupOptions,
|
||||
SubagentReportDelivery,
|
||||
SubagentReportMessageSource,
|
||||
SubagentReportOptions,
|
||||
} from './continuation.ts'
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
|
||||
@@ -156,6 +163,8 @@ declare module 'cordis' {
|
||||
export class SubagentService extends Service {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
private continuations: SubagentContinuationManager | undefined
|
||||
/** Deployment contributions composed into unpublished continuable children. */
|
||||
private readonly setupRegistry = new SubagentActivationSetupRegistry()
|
||||
/**
|
||||
* The contained lifecycle-edge publisher. Built here because scoped dispatch
|
||||
* keys its carrier by this exact service instance, whose own context filter
|
||||
@@ -170,7 +179,7 @@ export class SubagentService extends Service {
|
||||
const manager = new SubagentContinuationManager(childCtx, {
|
||||
prepareContinuable: (name, request) => this.prepareContinuable(name, request),
|
||||
observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent),
|
||||
})
|
||||
}, this.setupRegistry)
|
||||
this.continuations = manager
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
|
||||
@@ -216,6 +225,41 @@ export class SubagentService extends Service {
|
||||
return this.requireContinuations().followup(parent, childId, content, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver selected content from one live continuable child to its durable
|
||||
* direct parent. The child is the authority credential; callers cannot name a
|
||||
* recipient. Reporting does not conclude the child's turn or Activation.
|
||||
* @param child - exact live reporting child.
|
||||
* @param content - selected model-facing content.
|
||||
* @param options - parent scheduling and pre-acceptance cancellation.
|
||||
* @returns the stable identity of the parent-accepted message.
|
||||
* @throws when continuation services are unavailable, sender authorization
|
||||
* fails, or the direct parent is not live.
|
||||
*/
|
||||
async reportFrom(
|
||||
child: Agent,
|
||||
content: ContentBlock[],
|
||||
options: SubagentReportOptions,
|
||||
): Promise<MessageId> {
|
||||
return this.requireContinuations().reportFrom(child, content, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one deployment capability into every continuable child's
|
||||
* unpublished creation context on fresh creation and cold resume. Grants wait
|
||||
* for the next Activation; removing the contribution revokes every resident
|
||||
* installation immediately.
|
||||
* @param contribution - synchronous child-scope installer.
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
registerContinuableSetup(contribution: ContinuableSetupContribution): () => void {
|
||||
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(
|
||||
() => this.setupRegistry.register(contribution),
|
||||
'subagents.registerContinuableSetup()',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close continuable admission below exact live parent Agents, stop only their
|
||||
* visible descendant Activations synchronously, then await admitted scoped
|
||||
@@ -224,7 +268,7 @@ export class SubagentService extends Service {
|
||||
* remain live.
|
||||
* @param parents - exact host-owned parent Agents entering teardown.
|
||||
* @returns once every retained descendant Activation released its `AgentHandle`.
|
||||
* @throws an aggregate error after all scoped branches settle when any failed.
|
||||
* @throws an aggregate error after all branches settle when any failed.
|
||||
*/
|
||||
async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> {
|
||||
const manager = this.continuations
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SubagentActivationSetupRegistry from '../src/activation-setup-registry.ts'
|
||||
|
||||
/** A child-like scoped context with observable disposal. */
|
||||
function childContext(): { ctx: Context; close: () => Promise<void> } {
|
||||
const root = new Context()
|
||||
const scope = root.plugin(function child() {})
|
||||
return { ctx: scope.ctx, close: async () => { await scope.dispose() } }
|
||||
}
|
||||
|
||||
describe('SubagentActivationSetupRegistry', () => {
|
||||
it('installs contributions in registration order and commits them', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const order: string[] = []
|
||||
registry.register(() => { order.push('first'); return () => order.push('undo-first') })
|
||||
registry.register(() => { order.push('second'); return () => order.push('undo-second') })
|
||||
const child = childContext()
|
||||
|
||||
const transaction = registry.apply(child.ctx)
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
expect(() => { transaction.assertIntact() }).not.toThrow()
|
||||
transaction.commit()
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('makes repeated removal and converging ownership idempotent', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const remove = registry.register(() => () => { disposals += 1 })
|
||||
const child = childContext()
|
||||
registry.apply(child.ctx).commit()
|
||||
|
||||
remove()
|
||||
remove()
|
||||
await child.close()
|
||||
expect(disposals).toBe(1)
|
||||
})
|
||||
|
||||
it('makes the opposite ownership convergence idempotent', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const remove = registry.register(() => () => { disposals += 1 })
|
||||
const child = childContext()
|
||||
registry.apply(child.ctx).commit()
|
||||
|
||||
await child.close()
|
||||
remove()
|
||||
expect(disposals).toBe(1)
|
||||
})
|
||||
|
||||
it('skips a contribution removed before a child is applied', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const installed: string[] = []
|
||||
const remove = registry.register(() => { installed.push('gone'); return () => {} })
|
||||
registry.register(() => { installed.push('kept'); return () => {} })
|
||||
remove()
|
||||
|
||||
registry.apply(childContext().ctx).commit()
|
||||
expect(installed).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('invalidates a provisioning batch revoked before commit', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const remove = registry.register(() => () => { disposals += 1 })
|
||||
const transaction = registry.apply(childContext().ctx)
|
||||
|
||||
remove()
|
||||
expect(disposals).toBe(1)
|
||||
expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/)
|
||||
})
|
||||
|
||||
it('catches a contribution revoked inside its own installer', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const self: { remove?: () => void } = {}
|
||||
self.remove = registry.register(() => {
|
||||
self.remove?.()
|
||||
return () => { disposals += 1 }
|
||||
})
|
||||
|
||||
const transaction = registry.apply(childContext().ctx)
|
||||
expect(disposals).toBe(1)
|
||||
expect(() => { transaction.assertIntact() }).toThrow(/revoked/)
|
||||
})
|
||||
|
||||
it('attempts every contribution-removal disposer before reporting failures', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const released: string[] = []
|
||||
let seq = 0
|
||||
const remove = registry.register(() => {
|
||||
const id = `child-${++seq}`
|
||||
return () => {
|
||||
released.push(id)
|
||||
if (id === 'child-1') throw new Error('disposer exploded')
|
||||
}
|
||||
})
|
||||
for (const child of [childContext(), childContext(), childContext()]) {
|
||||
registry.apply(child.ctx).commit()
|
||||
}
|
||||
|
||||
expect(() => { remove() }).toThrow(/failed to release 1 installation\(s\)/)
|
||||
expect(released).toEqual(['child-1', 'child-2', 'child-3'])
|
||||
})
|
||||
|
||||
it('attempts every child-scope disposer before reporting failures', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const released: string[] = []
|
||||
registry.register(() => () => {
|
||||
released.push('a')
|
||||
throw new Error('first disposer exploded')
|
||||
})
|
||||
registry.register(() => () => { released.push('b') })
|
||||
const child = childContext()
|
||||
registry.apply(child.ctx).commit()
|
||||
|
||||
await child.close().catch(() => undefined)
|
||||
expect(released).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('rolls back earlier installations when a later contribution throws', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const undone: string[] = []
|
||||
registry.register(() => () => undone.push('first'))
|
||||
registry.register(() => { throw new Error('boom') })
|
||||
registry.register(() => () => undone.push('third'))
|
||||
|
||||
expect(() => registry.apply(childContext().ctx)).toThrow(/boom/)
|
||||
expect(undone).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('does not dispose twice when revocation precedes setup rollback', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const disposals: string[] = []
|
||||
const removeFirst = registry.register(() => () => { disposals.push('first') })
|
||||
registry.register(() => {
|
||||
removeFirst()
|
||||
throw new Error('second failed after revoking the first')
|
||||
})
|
||||
|
||||
expect(() => registry.apply(childContext().ctx)).toThrow(/second failed/)
|
||||
expect(disposals).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('does not cross-release independent child scopes', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const disposed: string[] = []
|
||||
let seq = 0
|
||||
registry.register(() => {
|
||||
const id = `child-${++seq}`
|
||||
return () => disposed.push(id)
|
||||
})
|
||||
const first = childContext()
|
||||
const second = childContext()
|
||||
registry.apply(first.ctx).commit()
|
||||
registry.apply(second.ctx).commit()
|
||||
|
||||
await first.close()
|
||||
expect(disposed).toEqual(['child-1'])
|
||||
await second.close()
|
||||
expect(disposed).toEqual(['child-1', 'child-2'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user