Settlement delivery belongs to the continuation manager
Settlement delivery belongs to the continuation manager
Continuable background delegation was the one asynchronous operation a model could start but could not reach the end of. Every other shape has a retrieval primitive or a return value: a background bash command and a one-shot background subagent both settle through a Task that `job_output(wait: true)` can block on, a workflow and a foreground subagent return their result to the caller. A continuable background child r
English
Problem
Continuable background delegation was the one asynchronous operation a model could start but could not reach the end of. Every other shape has a retrieval primitive or a return value: a background bash command and a one-shot background subagent both settle through a Task that job_output(wait: true) can block on, a workflow and a foreground subagent return their result to the caller. A continuable background child returned only its durable id, and nothing existed that a parent could wait on or would be handed.
The report obligation closed the cooperative half of that gap by instructing the child to report before it finishes. Instruction cannot close the rest. A child stopped by a token ceiling, a model failure, cancellation, or teardown never reaches the point where it could comply — not rarely, but never — and those are precisely the endings a waiting parent most needs to hear about. The observable downstream symptoms were parents busy-polling list_agents, re-sending messages to children that had already settled, and deployments abandoning subagent for workflow because a workflow at least returns something.
The signal already existed. subagent/end has carried stopReason and lastAssistantMessage since continuable Activations shipped. What was missing was any consumer that turned it into context the parent's model could see.
Decision
The continuation manager delivers the account itself, from inside the disposal transaction that ends the Activation.
When a resident Activation settles, notifySettlement() resolves the child's durable direct parent and sends it one user-role message: the epoch's outcome as a sentence the parent can act on, then the child's final assistant content, or a statement that it produced none. Delivery is unconditional for every child whose id a caller actually received. It does not consult whether the child reported, and it keeps no bookkeeping that could make the promise conditional — that unconditionality is what lets tool-subagent promise a runtime notice containing the outcome and any final assistant message. A materialization rolled back before its first accepted message stays silent, because the caller was told that child was not established.
Provenance
The notice carries { kind: 'subagent-settled', form: 'notice', summary, senderSessionId }. It is deliberately not the existing subagent-report kind. A report is content the child chose; this is the runtime stating what became of the child. Merging them would credit the child with words it never wrote, and would make a durable log unable to distinguish "the child said it was done" from "the harness observed that it stopped". The notice form also gives a UI the collapsed one-line presentation this message wants, where relay would present it as correspondence.
Two ordering rules, and why the manager owns them
An external ctx.on('subagent/end') listener looks more decoupled and is wrong. SubagentRunEndInfo names no parent, the child handle is already disposed when the edge fires so the parent cannot be recovered from it, and the ownership release that wakes the parent's own settlement watcher has already run. The manager holds the parent reference throughout disposal, so none of those obstacles exist for it.
The send happens before releaseOwnership. At that point the parent still counts this child, so stateOf(parent) is waiting and the parent is structurally unable to be judged settled. Delivering after the release instead races a watcher that resumes one microtask later, finds itself childless and quiet, and disposes an Agent whose cancel() clears the very inbox the notice is sitting in. The failure mode is a silently missing message with no error anywhere.
A resident parent receives it through admitWaking. Registering the message id before the synchronous send is what keeps the window between followup() and the microtask that admits it from being read as quiescence. This is not belt-and-braces over the first rule: Agent.status folds context maintenance into idle, and a waking send behind maintenance only arms a deferred wake, so a parent compacting its context is judged quiet by both status and the owned-child set the moment the release lands.
Both rules are pinned by tests that fail when the ordering is reversed or the accounting removed.
Scheduling
An idle parent gets one ordinary later turn. A busy parent is steered into its nearest step boundary, because Inbox.claim() takes the whole next-step batch at one boundary: four children settling together then cost one step rather than four turns. Steering rather than injecting is deliberate — the wake is a no-op while the driver is running, and it closes the window where a driver retires between the status read and the send, which would strand the notice unclaimed until something unrelated woke the parent. This is a correctness rule, not a deployment preference, so it is not a Config field.
One running parent is not steerable: one whose turn is already cancelled but has not yet exited. Agent.send() redirects waking input submitted after cancellation to the next turn, latches the wake, and replays it once the cancelled driver converges — except for a disposal cancellation, which never latches and belongs to the teardown rule below. The notice therefore still opens its own turn without waiting for unrelated input; the cost is a redirected turn boundary, not the message.
A parent whose own teardown began gets no wake. Waking is not a queue operation: Agent.followup() on a quiescent Agent starts a turn, and cancel() on an idle Agent is a documented no-op that does not arm against a later one. Every teardown path therefore ends with a live, cancelled, still-registered parent — drainContinuableDescendants() is called by the ACP bridge between cancelling its session agents and disposing them — so an unguarded notice starts a real model request on an Agent about to be destroyed, once per tree layer, because each layer's own notice then wakes the layer above it. notifySettlement() asks the same question assertAdmitting() asks (is this lineage's continuable admission closed?) and injects instead. Injection is not a durable mailbox — Accepted risks records what the parent's own disposal then does to it — but it is the only send that reaches a parent still reading its inbox without arming a turn on one that is not, and nothing is lost that the wake would have delivered: the turn a wake started was itself disposed mid-flight.
Delivery never blocks or fails teardown. A rejected send is logged and dropped, because retaining a child to retry a notice would pin its whole ancestry in waiting forever, and a parent that has left the registry is an ordinary outcome rather than an error.
The epoch's own log is the whole account
epochStopReason() reads the epoch's outcome from its own log, because teardown succeeding says nothing about whether the model errored, hit its ceiling, or was stopped. Reading turns alone got that wrong twice, in the same shape both times: a turn stopped before its first step leaves a turn/end indistinguishable from the balanced no-op turns a rejection or an emptied claim produces, so the filter that skipped those also skipped real endings and answered with the previous turn's clean completion. The durability checkpoint (dsh-session-checkpoint-policy, in every shipped profile) and prompt assembly both run at that boundary and both propagate, and Inbox.claim() has already taken the messages by then — so the parent was told a child finished while the delivery it was waiting on had been swallowed. Under the advertised automatic settlement notice, that is the one failure a parent cannot detect and will not retry.
The missing fact was never the turn's; it was the inbox's. Inbox logs every mutation with removedCount and marks a cancellation outcome: 'canceled', which separates a turn claiming its input from work being dropped unrun. foldConsumedWork() in dsh-agent folds both vocabularies into one answer: the latest turn that accounts for consumed work — stepped, or claimed-then-failed, stopped, or rejected — and whether accepted work was cancelled after it with no turn opening over it. A blocked end over claimed input is an account too: the pre-step rejection that produced it — a hook deny, a policy plugin — discarded the messages the turn claimed, so the notice says the child declined rather than finished. Only a blocked turn that claimed nothing stays invisible.
Deriving it from the log rather than from live state is what makes it whole. An earlier version sampled the manager's own Activation immediately before cancelling, which could only ever see cancellations this manager was about to perform: an ancestor's interrupt(), or an unloading plugin cancelling an agent it tracks, left the sample false and the notice still saying finished. It also left the accepted-but-never-claimed case pinned to nothing a test could distinguish from its absence. One fold over the log covers every issuer, and both halves fail their own tests when removed.
Precedence is the consumer's: a recorded failure or ceiling wins over a cancellation, because stopping a child that had already failed does not turn its failure into a cancellation. dsh-agent owns the fold because it owns the inbox marker the answer depends on, and both consumers already depend on it — the continuable epoch here, and the one-shot readResult(), which had the same hole.
Both matter past the notice: subagent/end carries stopReason to the jsonrpc UI and the Claude hook bridge, which reported a torn-down mid-turn child as completed.
Snapshot coverage
Three assembled ACP scenarios cover the notice: a child that never reports, a child that reports first, and a child driven through several follow-up turns. All three needed an explicit fence. The notice arrives once the child's teardown finishes, which races whatever the parent is already doing, so each scenario holds the child behind the parent's spawn turn and then waits for the parent turn the notice opens (waitForTurnStart at that turn, then waitForTurnEnd) before the script continues. Waiting for a turn the run is not fenced to produce is not coverage: it is a timeout when the notice lands in the turn already running instead.
subagent-continuable is the one that pins a failure. Its child's last turn dies on the forced durability checkpoint without entering a step, so that transcript is where the stop-reason rule above is visible end to end: the notice says the child failed, carries the earlier SECOND_OK as its last content rather than as a result, and the parent's own acknowledgement turn reaches the ACP client.
A keyless headless Loader snapshot covers the user-visible path end to end. Its replay parent omits run_in_background to exercise the continuable background default, never calls list_agents, send_message, or Task tools, consumes the manager-authored subagent-settled notice, and produces its final answer. The child never calls report, so the transcript cannot pass through the cooperative report path. A test-only Loader fence holds the parent's post-spawn request until the real manager notice enters its inbox, removing platform scheduling from the transcript without synthesizing the notice.
The subagent-report scenario uses the default next-step report delivery. A snapshot-only fence holds the child until the parent's spawn turn ends, then holds the parent in maintenance until settlement follows the report. The resumed parent claims the next-step report before the queued next-turn settlement. The report/settlement ordering decision owns this cross-state ordering.
The refusal and interruption wordings are pinned verbatim in unit tests rather than in a replayed transcript: producing them needs a rejecting policy plugin or a cancellation fenced at a step boundary, which the keyless assemblies do not otherwise carry, and the assembled scenarios already pin the notice pathway itself end to end.
Alternatives considered
Give continuable children a Task. A Task is a one-shot contract: one producer, one settlement, one result. An Activation runs many turns, outlives any single one, and can be resumed after it ends. Wrapping it in a Task recreates exactly the lifetime mismatch continuable children were introduced to remove, and would make one turn look terminal.
Attach an external subagent/end listener. Rejected on three counts above — no parent in the payload, a disposed child handle, and an ordering the listener cannot influence. A listener would also have to be strictly synchronous to beat the release, and nothing at that seam enforces it, so the correct version would be correct only by accident.
Deliver only when the child did not report. This was the first design. It needs per-Activation bookkeeping, still misses the child that reported progress and then died before its result, and — decisively — makes the parent-facing promise conditional. "Usually you are told" is not a contract a tool description can state, and a model that cannot rely on the notice will poll anyway.
Make delivery configurable. A deployment switch would return the model-facing text to "usually", which is the failure this change exists to remove. Protocol constants and safety invariants stay fixed; this is one of them.
Change subagent/end to carry the parent, and let a plugin deliver. That widens a published payload for one in-package consumer, keeps every ordering hazard, and makes the return channel an optional plugin again. Extending the package-private ActivationObserver with terminal(failure) keeps one computation of the terminal facts and no public surface change.
Always use followup. Simpler and uniform, but a fan-out of children settling together would cost one parent turn each. The step-boundary batch already exists; using it is free.
Consequences
- A continuable child's parent receives one message per settled Activation. Fan-out deployments therefore add parent turns; steering keeps a simultaneous batch to one step.
tool-subagentpromises the notice in its schema because the return channel is service behavior, not an optional plugin.ActivationcarriesparentSessionandannounced. The first exists because the child handle is disposed before delivery; the second is what keeps a rolled-back materialization silent.foldConsumedWork()replacesdsh-session'sfindLastMessageTurnEnd()and moves todsh-agent, which owns the inbox marker it reads; the one-shot in-process path folds the same answer and does not classify a cut-short one-shot child ascompleted.- Unit coverage pins the unconditional contract, each terminal reason, idle and busy scheduling, the batch, the maintenance regression, the pre-release ordering, a parent that is gone, and a rejected send that must not fail teardown.
- Three ACP scenarios use an explicit settlement fence, and
subagent-reportpins the default report-before-settlement next-step order. - A keyless headless Loader snapshot pins background start → manager-authored settlement notice → final parent answer with no polling or child
reportcall.
Accepted risks
The notice is delivered, not confirmed. There is no durable mailbox, receipt, or retry: a parent that is not live loses it, and the child's Session remains the only durable record. Closing that needs an offline mailbox protocol with its own addressing, authorization, and replay rules.
A notice injected during teardown is not read by a model when that parent is disposed next, which every teardown caller does: the disposal cancel clears the unclaimed message and the log keeps the insert/cancel pair as the record. Making teardown delivery readable after resume requires either the offline mailbox above or a change to disposal of durable pending work. Disposal discards every unclaimed inbox item, including user input, so changing that behavior is a core-agent decision rather than a settlement-delivery detail. After resume, the parent can discover the child but does not receive the outcome: list_agents reports existence and live-or-stored status only — SubagentListEntry.activity says so — and recovering the ending requires asking the child through send_message.
Stop-reason attribution is a best effort over the log's existing splice vocabulary, biased against overstating success. Inbox.remove() and teardown's clear() write identical cancellation splices, so removing a message whose content survives elsewhere — agent-instructions vacuuming a pending instruction refresh, or settlement's own cancel clearing one left pending — can read as work dropped unrun and report a finished child as stopped. Separating them requires a richer removal vocabulary in dsh-agent; without it, the misread is narrow and errs toward the parent double-checking a finished child, never toward trusting an unfinished one.
Turn amplification is real for deep or wide trees, and it is not configurable by design. The step-boundary batch bounds it for simultaneous settlement but not for children that settle apart.
Reports and their later settlement notices are ordered through the parent's next-step FIFO. Independent settlements from sibling children retain their actual delivery order rather than a synthetic sibling ordering.
中文
问题
可继续后台委派是模型唯一一种能够发起、却无法抵达终点的异步操作。其他每一种形态都有取回原语或返回值:后台 bash 命令与一次性后台 subagent 都通过 Task 结算,job_output(wait: true) 可以阻塞等待;workflow 与前台 subagent 会把结果返回给调用方。可继续后台 child 只返回它持久化的 id,而父级既没有可等待的对象,也不会被交付任何东西。
报告义务通过要求 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 场景使用默认 next-step 报告投递。一个仅用于快照的围栏会让 child 等到 parent 的派生轮次结束,随后让 parent 保持 maintenance,直至结算跟在报告之后到达。恢复的 parent 会先领取 next-step 报告、再领取排队的 next-turn 结算。报告与结算顺序决策负责说明这种跨状态顺序。
拒绝与中断两种措辞在单元测试中逐字钉死,而不进入重放 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固定默认的报告先于结算的 next-step 顺序。 - 一个无密钥的 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。
报告与其稍后的结算通知通过 parent 的 next-step FIFO 排序。来自同级 child 的独立结算保留其实际投递顺序,不会虚构同级间的顺序。