# Agent Note: Settlement delivery belongs to the continuation manager

Status: implemented

[English](2026-08-06-manager-owned-subagent-settlement-delivery.md) | 中文

## 问题

可继续后台委派是模型唯一一种能够发起、却无法抵达终点的异步操作。其他每一种形态都有取回原语或返回值：后台 bash 命令与一次性后台 subagent 都通过 Task 结算，`job_output(wait: true)` 可以阻塞等待；workflow 与前台 subagent 会把结果返回给调用方。可继续后台 child 只返回它持久化的 id，而父级既没有可等待的对象，也不会被交付任何东西。

[报告义务](2026-08-06-continuable-child-report-obligation.md)通过要求 child 在结束前上报，补上了这一缺口中协作的那一半。指令无法补上其余部分。被 token 上限、模型失败、取消或拆卸终止的 child 永远走不到能够遵守的那一步——不是很少，而是从不——而这些恰恰是等待中的父级最需要被告知的结束方式。可观察到的下游症状包括：父级忙轮询 `list_agents`、向已经结算的 child 反复发送消息，以及部署放弃 `subagent` 转用 `workflow`，因为 workflow 至少会返回点什么。

信号本身早就存在。自可继续 Activation 发布以来，`subagent/end` 就一直携带 `stopReason` 与 `lastAssistantMessage`。缺的是把它变成父级模型能看到的上下文的那个消费者。

## 决策

继续执行管理器自己投递这份记账，就在结束 Activation 的那笔 dispose 事务内部完成。

当驻留 Activation 结算时，`notifySettlement()` 解析该 child 持久化的直接父级，并向它发送一条用户角色消息：先是父级可据以行动的一句结果说明，然后是 child 的最终 assistant 内容，或一句说明它没有产出内容。对每个调用方真正拿到过 id 的 child，投递都是无条件的。它不查询 child 是否上报过，也不保留任何可能让这项承诺变成有条件的记账——正是这种无条件性，才让 `tool-subagent` 能够承诺一条包含结局与可能存在的最终 assistant 消息的运行时通知。在第一条消息被接受之前就回滚的物化保持静默，因为调用方已被告知该 child 未建立。

### 来源信息

该通知携带 `{ kind: 'subagent-settled', form: 'notice', summary, senderSessionId }`，刻意不复用既有的 `subagent-report` kind。上报是 child 选择的内容；这条消息则是运行时在陈述这个 child 后来怎样了。把两者合并会把 child 从未写过的话算到它头上，也会让持久化日志无法区分「child 说它做完了」和「harness 观察到它停下了」。`notice` 形态还为 UI 提供了这条消息想要的折叠单行呈现，而 `relay` 会把它呈现为往来信件。

### 两条顺序规则，以及为什么归管理器所有

外部 `ctx.on('subagent/end')` listener 看起来更解耦，但它是错的。`SubagentRunEndInfo` 不指名父级；该边触发时 child handle 已被 dispose，因此无法从中恢复父级；而唤醒父级自身结算 watcher 的所有权释放也已经执行过了。管理器在整个 dispose 过程中都持有父级引用，因此这些障碍对它都不存在。

**发送发生在 `releaseOwnership` 之前。** 此刻父级仍然计入这个 child，因此 `stateOf(parent)` 为 `waiting`，父级在结构上不可能被判定为已结算。改在释放之后投递，则会与一个在下一个 microtask 恢复的 watcher 竞争：它会发现自己没有 child 且处于静止，于是 dispose 一个 Agent，而该 Agent 的 `cancel()` 会清空正装着这条通知的那个 inbox。失效表现是一条静默丢失的消息，任何地方都不会报错。

**驻留父级通过 `admitWaking` 接收它。** 在同步发送之前登记消息 id，正是让 `followup()` 与承认它的那个 microtask 之间的窗口不被读作静止的原因。这不是对第一条规则的多余保险：`Agent.status` 会把上下文维护折叠成 `idle`，而维护期间的唤醒发送只会预置一次延后唤醒，因此正在压缩上下文的父级，在所有权释放落地的那一刻会同时被 `status` 与已拥有 child 集合判定为静止。

两条规则都有测试固定：把顺序反转或去掉记账，测试就会失败。

### 调度

空闲父级得到一个普通的后续轮次。繁忙父级则被 steer 到其最近的 step 边界，因为 `Inbox.claim()` 会在一个边界上整批取走 next-step：四个 child 同时结算时因此只消耗一个 step，而不是四个轮次。采用 steer 而非 inject 是刻意的——驱动运行期间该唤醒是空操作，同时它关闭了「驱动在状态读取与发送之间退出」的那个窗口；否则通知会滞留无人认领，直到别的事件唤醒父级。这是正确性规则而非部署偏好，因此不做成 `Config` 字段。

有一种 `running` 父级是无法 steer 的：轮次已被 cancel 但尚未退出的那种。`Agent.send()` 会把取消之后提交的唤醒输入改投到下一个轮次、闩存这次唤醒，并在被取消的驱动收敛后重放它——只有 disposal 取消从不闩存，那属于下面的拆卸规则。因此通知仍会开启自己的轮次，无需等待无关输入；代价是一次被改投的轮次边界，而不是消息本身。

**自身已开始拆卸的父级不会被唤醒。** 唤醒不是入队操作：对静息 Agent 调用 `Agent.followup()` 会开启一个轮次，而对空闲 Agent 调用 `cancel()` 是文档明确的空操作，不会对之后的轮次设防。因此每条拆卸路径最终都面对一个在线、已取消、仍在注册表中的父级——ACP 桥接层正是在取消其 session agent 与 dispose 它们之间调用 `drainContinuableDescendants()`——于是一条无防护的通知会在一个即将被销毁的 Agent 上发起真实模型请求，而且每层树各一次，因为每层自己的通知又会唤醒它上面那层。`notifySettlement()` 会问 `assertAdmitting()` 问的同一个问题（这条谱系的可继续准入是否已关闭？），并改为 inject。inject 不是持久 mailbox——父级自身的 dispose 会对它做什么，记在「已接受的风险」里——但它是唯一能送达仍在读取自身 inbox 的父级、又不会在不该被唤醒的父级上预置一个轮次的发送方式；而唤醒本可送达的东西一样没有丢失：唤醒开启的那个轮次本身就会在半途被 dispose。

投递绝不会阻塞或使拆卸失败。发送被拒会被记录并丢弃，因为为重试一条通知而保留 child，会把它的整条祖先链永久钉在 `waiting` 上；而父级已离开注册表属于普通结果，不是错误。

### epoch 自己的日志就是全部交代

`epochStopReason()` 从 epoch 自己的日志读取结局，因为拆卸成功与否，对「模型是否报错、是否撞到上限、是否被停下」什么也没说明。只读轮次这件事已经错了两次，而两次的形状相同：在第一个 step 之前被停下的轮次，其 `turn/end` 与「拒绝」或「被清空的认领」产生的平衡空转轮次长得一模一样，于是那道用来跳过后者的过滤，也把真实的结局一起跳过了，转而用上一个轮次的干净收尾作答。持久化检查点（`dsh-session-checkpoint-policy`，存在于每个随附 profile 中）与提示词组装都运行在这个边界上、且都会向外传播，而此时 `Inbox.claim()` 已经把消息取走了——于是父级被告知 child 已完成，而它正在等待的那条投递已被吞掉。在已公布的自动结算通知约定下，这恰恰是父级无法察觉、也不会重试的那一种失败。

缺失的事实从来不属于轮次，而属于 inbox。`Inbox` 会把每次改动连同 `removedCount` 一起记入日志，并给取消标记 `outcome: 'canceled'`，这就把「某个轮次认领了它的输入」与「工作被丢弃且从未运行」区分开来。`dsh-agent` 中的 `foldConsumedWork()` 把两套词汇折叠成一个答案：能为已消费工作作出交代的最新轮次——进入过 step 的，或认领后失败、被停下或被拒绝的——以及此后是否有已接受的工作被取消、而没有任何轮次为它开启过。认领过输入、以 `blocked` 结束的轮次同样是一份交代：产生它的 pre-step 拒绝——hook deny、策略插件——把该轮次认领的消息一并丢弃了，因此通知会说 child 拒绝了任务，而不是完成了任务。只有没认领任何输入的 `blocked` 轮次保持不可见。

从日志而不是从活动状态推导，才让它完整。早先的版本会在 cancel 之前立刻采样管理器自己的 Activation，而那样只能看到本管理器即将执行的取消：来自祖先的 `interrupt()`，或某个正在卸载的插件取消它所跟踪的 Agent，都会让该采样为假，通知照旧说 `finished`。它也让「已接受但从未被认领」这一情形没有任何测试能把它与「该判据不存在」区分开。一次对日志的折叠覆盖了所有发起方，而两个半边在被移除时都会让各自的测试失败。

优先级归消费方：已记录的失败或上限优先于取消，因为停下一个已经失败的 child，不会把它的失败变成一次取消。`dsh-agent` 拥有这个 fold，是因为答案所依赖的那个 inbox 标记归它所有，而两个消费方本来就依赖它——这里的可继续 epoch，以及一次性的 `readResult()`（它有同一个漏洞）。

两者的影响都超出通知本身：`subagent/end` 会把 `stopReason` 送到 jsonrpc UI 与 Claude hook 桥接层，而它们此前把被拆卸的、正在跑轮次的 child 报成 `completed`。

### 快照覆盖

三个整体组装的 ACP 场景覆盖该通知：一个从不上报的 child、一个先上报的 child，以及一个被多轮 follow-up 驱动的 child。三者都需要显式栅栏。通知在 child 拆卸完成后才到达，会与父级当时正在做的事竞争，因此每个场景都会把 child 保持到父级启动轮次结束，再等待该通知开启的那个父级轮次（先 `waitForTurnStart` 到该轮次，再 `waitForTurnEnd`），然后脚本才继续。等待一个运行并未被栅栏保证会产生的轮次不算覆盖：一旦通知落进已经在跑的那个轮次，它就是一次超时。

`subagent-continuable` 是其中固定失败结局的那个。它的 child 最后一个轮次在被强制的持久化检查点上死亡，且未进入任何 step，因此该 transcript 正是上面那条终止原因规则的端到端可见之处：通知说该 child **失败**，把此前的 `SECOND_OK` 作为它最后产出的内容而非结果携带，而父级自己的确认轮次会到达 ACP 客户端。

另有一个无密钥的 headless Loader 快照端到端覆盖用户可见路径。其重放父级省略 `run_in_background` 以覆盖可继续后台默认路径，从不调用 `list_agents`、`send_message` 或 Task 工具，消费管理器写入的 `subagent-settled` 通知，并给出最终答案。child 从不调用 `report`，因此该 transcript 不可能经由协作式上报路径通过。一个仅用于测试的 Loader 栅栏会把父级启动后的请求保持到真实管理器通知进入其 inbox 为止，从 transcript 中排除平台调度差异，但不会伪造该通知。

`subagent-report` 还需要多做一步让步。在随附的唤醒上报默认值下，该场景有两个互相独立的父级唤醒——上报与结算——而第二个究竟是延长第一个的轮次还是另开一个轮次，是一枚真正的硬币，多次运行实测约为五五开。任何手写 transcript 都无法同时容纳两种顺序。因此它的 overlay 固定 `reportDelivery: quiet`，使结算成为唯一唤醒；另一个仅用于快照的 pre-step 栅栏会把 child 保持到父级启动轮次结束，使这次唤醒开启一个确定轮次并同时认领两条消息。唤醒上报默认值的覆盖则保留在 report 包自身的测试中。

拒绝与中断两种措辞在单元测试中逐字钉死，而不进入重放 transcript：触发它们需要一个会拒绝的策略插件、或一次在 step 边界被栅栏卡住的取消，而无密钥组装本身并不携带这些；通知通路本身已由整体组装场景端到端钉住。

## 考虑过的替代方案

**给可继续 child 引入 Task。** Task 是一次性契约：一个生产者、一次结算、一个结果。Activation 会执行许多轮次、比其中任何一轮活得更久，并且可以在结束后被恢复。用 Task 包装它，恰好重建了可继续 child 当初为消除而引入的生命周期错配，还会让某一个轮次看起来是终局。

**挂一个外部 `subagent/end` listener。** 因上文三点被否决——payload 里没有父级、child handle 已被 dispose，以及 listener 无法影响的顺序。listener 还必须严格同步才能抢在释放之前，而该 seam 上没有任何东西强制这一点，因此正确的版本只能靠碰巧正确。

**仅在 child 没有上报时投递。** 这是最初的设计。它需要按 Activation 记账，仍会漏掉「报了进度、随后在给出结果前死掉」的 child，而且最关键的是：它让面向父级的承诺变成有条件的。「通常你会被告知」不是工具描述能陈述的契约，而无法依赖该通知的模型无论如何都会去轮询。

**把投递做成可配置。** 部署开关会把面向模型的文本重新变回「通常」，而这正是本次改动要消除的失效。协议常量与安全不变量保持固定；这就是其中之一。

**修改 `subagent/end` 让它携带父级，由插件负责投递。** 那会为一个包内消费者拓宽已发布的 payload，保留全部顺序风险，并让返回通道重新变成可选插件。以 `terminal(failure)` 扩展包私有的 `ActivationObserver`，则只保留一处终止事实的计算，且不改动任何公开面。

**始终使用 `followup`。** 更简单也更统一，但一批同时结算的 child 会各自消耗一个父级轮次。step 边界的批量语义本来就存在，用它是免费的。

## 后果

- 可继续 child 的父级会为每个已结算 Activation 收到一条消息。因此，做扇出的部署会增加父级轮次；steer 会把同时结算的一批压缩到一个 step。
- `tool-subagent` 在其 schema 中承诺该通知，因为返回通道是服务行为，不是可选插件。
- `Activation` 携带 `parentSession` 与 `announced`。前者存在是因为 child handle 在投递前已被 dispose；后者让被回滚的物化保持静默。
- `foldConsumedWork()` 取代 `dsh-session` 的 `findLastMessageTurnEnd()`，并迁移到 `dsh-agent`——它拥有该 fold 所读取的 inbox 标记；一次性 in-process 路径折叠同一个答案，不会把被中途切断的一次性 child 归类为 `completed`。
- 单元覆盖固定了无条件约定、每种终止原因、空闲与繁忙两种调度、批量语义、维护期回归、释放前顺序、父级已消失，以及一次不得让拆卸失败的发送被拒。
- 三个 ACP 场景使用显式的结算栅栏，`subagent-report` 带有固定静默上报投递的配置 overlay。
- 一个无密钥的 headless Loader 快照固定了「后台启动 → 管理器写入的结算通知 → 父级最终答案」路径，其中没有轮询，也没有 child `report` 调用。

### 已接受的风险

通知只是被投递，而不是被确认。没有持久化 mailbox、回执或重试：不在线的父级会丢失它，child 的 Session 仍是唯一的持久记录。要补上这一点，需要一套带有自身寻址、授权与重放规则的离线 mailbox 协议。

当父级紧接着被 dispose 时（每个拆卸调用方都会这么做），在拆卸期间被 inject 的通知不会被模型读到：dispose 的 cancel 会清除这条未被认领的消息，而日志保留 insert/cancel 这一对作为记录。要让拆卸期投递在 resume 之后仍可读，要么需要上面那套离线 mailbox，要么需要改变 dispose 对持久待处理工作的处理方式。dispose 会丢弃每一条未被认领的 inbox 项，用户输入也不例外，因此改变该行为是一个 core-agent 决策，而不是结算投递的细节。resume 后的父级可以发现 child，但不会收到结局：`list_agents` 只报告存在性与「在线/仅存储」状态——`SubagentListEntry.activity` 就是这么写的——要取回结局，必须通过 `send_message` 去问那个 child。

终止原因的归因是对日志既有 splice 词汇的尽力而为，偏向永不高估成功。`Inbox.remove()` 与拆卸的 `clear()` 写出的取消 splice 完全相同，因此删除一条内容仍保留在别处的消息——`agent-instructions` 清理待处理的 instructions 刷新、或结算自身的 cancel 清掉一条仍在挂起的这类消息——可能被读作「工作被丢弃且从未运行」，把已完成的 child 报成被停下。区分二者需要 `dsh-agent` 提供更丰富的删除词汇；在该词汇可用前，这项误读的范围很窄，且错的方向是让父级复查一个已完成的 child，而永远不是信任一个未完成的 child。

对于深或宽的树，轮次放大是真实存在的，而且按设计不可配置。step 边界的批量语义只能约束同时结算的情形，无法约束分散结算的 child。

两个互相独立的唤醒源无法在手写 transcript 中排序。整体组装覆盖分别固定它们，而不固定它们的交错。
