DSHarness 系统拆解 固定基线 47f943859b · 36 已复核 / 0 撰写中 / 36 章
English
编排·第 26 章

Inbox、Steering 与 Continuation

运行中输入如何被排队、认领、注入或定向到子 Agent

已复核上游 47f943859b范围: 分析消息类别、wake/next-step、claim、steer、follow-up、activation 与 turn 边界。

结论:Inbox 是可回放的调度协议,不是聊天输入框的临时数组

DeepSeek Harness 用两个有序 Inbox 列表表达输入意图:next-turn 保存“以后各开一个 Turn”的工作,next-step 保存“在最近可用 Step 边界一起进入”的 steering 或 context。followupsteerinject 只是对目标列表与 wake 行为的三种固定组合;真正决定消息何时可见的是 claim、pre-step admission、Turn 关闭检查与取消收敛。

其关键优点不是支持“运行中再发一句”,而是把待处理输入本身写进 Session Event Log:稳定 MessageId、插入、编辑、取消、认领、恢复和 UI 分类都能围绕同一事实工作。代价也很明确:已接受只表示 Inbox 已接收,不表示已执行、已产生回复,甚至不必然表示已经 fsync;running 表示整个 driver 活动,也不是某一条消息的完成句柄。

1. 先拆开六个容易混名的机制

名称进入哪里是否 wake边界语义
followupnext-turn普通后续输入;成功路径中每条各占一个后续 Turn
steernext-step尽量进入当前 Turn 的下一个 Step;空闲时会自己打开 Turn
injectnext-step安静地放入模型上下文;空闲时一直停放到别的 wake 到来
Tool continuation工具结果已在日志;additional context 进入 next-step由当前 driver 继续同一 Turn 的后续 Step,不是新的 follow-up
Continuable child follow-up子 Agent 的 next-turn永远是子 Agent 的后续 FIFO Turn,不能 redirect 当前 Turn
Child report / settlement notice父 Agent 的 next-turnnext-step按策略子→父消息;与父→子的 follow-up 完全不是同一方向

2. 队列类别不是 Message role:身份、来源和调度位置彼此正交

进入 Inbox 的对象仍是统一的不可变 UserMessage:有稳定 idrole: user、精确 content blocks 和必填 source。MessageSourceMap 可由插件扩展;freezeMessage 会 structured-clone 后深冻结,创建函数则分配新的 UUID。INBOX-MESSAGE-IDENTITY

维度回答的问题例子
Message role怎样进入 provider-neutral conversationuser
Source谁产生它、UI 应怎样解释user、plugin notice、coordinator relay、subagent report
Inbox target在哪个调度边界被 claimnext-turn / next-step
Web placementpending 状态显示在哪里queued / steering / context

因此,“steering message”不是第三种消息结构。它是 user-source 消息先从 next-step 被认领这一历史事实;同一个 role 的非用户 source 则会被 UI 解释为 context。

3. 两个列表由同一种 durable splice 重建;提交先于 live mutation

agent/inbox/spliced {
  target: next-turn | next-step,
  start,
  removedCount?,
  inserted: full UserMessage[],
  outcome?: canceled
}
唯一事件词汇

插入、编辑、删除、claim 和 clear 都归一化成同一种 splice;事件携带完整插入消息,删除只有在用户/API/取消路径上才标 outcome: canceled,loop claim 的纯删除不标取消。INBOX-EVENT-SCHEMA

Commit-first

Inbox.mutate() 先验证坐标与跨两列表的 MessageId 唯一性,再调用 session.append,成功后才修改内存数组并发 live notification。append 抛错时,Inbox 保持原样;同步 Session observer 看到的是 splice 之前的列表,可用事件坐标恢复被移除对象。INBOX-COMMIT-IDENTITY

这也是为什么 Web projection 不需要自己维护一套命令队列:它读取 Agent 当前列表,再把正在发布的 splice 应用一次,就得到权威 post-state。

4. Claim 有严格优先级:全部 next-step,然后至多一个 next-turn

每个提议 Step 的 claim(target)
  1. 删除并取得全部 next-step(按 FIFO)
  2. 若 target = next-turn,再删除 next-turn 头部一条
  3. 按上述顺序逐条发 claimed(message, turn)

Turn 第一个 Step:target = next-turn
同 Turn 后续 Step:target = next-step
优先级不是抢占

构造器只回放 seedLength 之后的 Inbox splice;claim 总是把当前所有 next-step 组成一个 batch,并只附带一条普通队列消息。它不会中断已经发出的模型请求,只能在下一个 pre-step 边界生效。INBOX-CLAIM-REPLAY

这给出一个常被忽略的组合:Turn 的第一个请求可以同时包含早已停放的注入上下文、steering,以及该 Turn 自己的普通 prompt;顺序固定为 next-step 批次在前、next-turn 头部在后。

5. Follow-up 保证普通输入的 FIFO Turn 映射,但 status 不会逐 Turn 闪烁

followup(message) 等价于 send(message, next-turn, true)。空闲时 wake 会同步把 phase 预留为 running,再异步启动 driver;运行中只需排队,因为活着的 driver 在当前 Turn 关闭后会检查 hasPending,换新 AbortController 并继续下一 Turn。INBOX-SEND-MAP

经性质测试验证

同步 burst、逐条等待以及两者混合三种调度都验证:失败自由路径中 N 条 follow-up 产生严格递增的 N 个 Turn,每个 Turn 恰好一条普通消息且不重排;整个 burst 的状态轨迹仍只有 running → idleINBOX-FIFO-PROPERTY

6. Steer 是“最近 Step + wake”,不是对正在飞行请求的流内追加

steer(message) 写入 next-step 并请求 wake。若 Agent 正在正常运行,wakeDriver() 不另开 driver:当前 driver 应在下一 Step 边界 claim 它。若 Agent 空闲,steer 自己打开一个 Turn;因此底层 API 并不要求 running,空闲 steer 最终仍会成为该新 Turn 的第一个输入,只是 replay 分类保留为 steering。INBOX-DRIVER-WAKE

语义强度

这里的 steering 是 boundary steering,而不是 provider 正在生成 token 时修改同一 HTTP request。它能改变下一次模型调用看到的日志前缀,却不能撤回已生成的 chunk,也不会自动取消当前 tool call。

Web composer 只在 ordinary session 的 running 状态提供 steer 选择,减少“空闲 steer 其实新开 Turn”的意外;但 Host 的直接 session.prompt(mode: steer) 仍接受该底层语义。

7. Inject 是“最近 Step但不 wake”;是否同 Turn完全由落点决定

注入时刻结果
Agent 空闲停放在 next-step,直到 follow-up/steer 或其他 waking delivery
当前 pre-step 尚未 claim进入本次 batch
pre-step 已 claim、正在 assemble/listener/model/tool错过本次 request,进入下一 Step
agent/turn-stopping listener 内结束检查之后重读列表,因此可再开同 Turn Step
取消后的 driver 正在收敛没有 wake latch;停放等待未来 wake
Claim cutoff

测试在第一个 pre-step listener 暂停后依次调用 inject 与 steer;两者都留在 next-step,第一请求完全看不到,第二请求按插入顺序看到。若当前 batch 被 reject,后来放入的 next-step 输入保留,但也需要新的 wake 才会运行。INBOX-PRESTEP-CUTOFF-TEST

8. Pre-step 先消费、后准入:reject 不会自动把原消息退回

turn/start
  → inbox.claim(target)                 // durable pure deletion
  → assemble system prompt
  → agent/pre-step waterfall
       ├─ enter(messages) → step/start → user/message* → model
       └─ reject          → turn/end(blocked),无 Step
一次性 claim

loop 在 listener 之前已经从 Inbox 删除完整 batch。listener 可以保留、替换或清空 messages;reject 将 Turn 记为 blocked,原 batch 不会被标 canceled、不会形成 user/message、也不会回到队列。首次 enter 的空数组则产生 completed 的无 Step Turn。INBOX-TURN-MACHINE

如果 assemble 或 listener 抛错,Turn 以 error 关闭;同一时刻尚未 claim 的 next-turn 与后来到达的 next-step 仍在队列,但 driver 停止。这里选择的是“input claim 是提交点”,而不是“模型调用成功才消费”。

9. Tool continuation 与 turn-stopping 都复用 next-step,却拥有不同生产者

模型给出 tool calls 后,结果按模型顺序写入日志;每个 finalized result 的 additionalContexts 随后依次 splice 到 next-step。工具未声明 concludesTurn 时,loop 在同一 Turn 开下一个 Step;声明 concluded 也不会越过已经存在的 next-step 输入。INBOX-TOOL-CONTEXTINBOX-TOOL-CONTINUATION

最后反悔点

当模型看起来可以结束且 next-step 暂空,loop await agent/turn-stopping,然后再次读取 next-step。listener 中的 steer 已由回归测试证明会强制第二个 Step;若 listener 注入的 batch 被 pre-step 重写为空,则不会多做模型调用。INBOX-TURN-STOPPING-TEST

10. running 属于 whole driver;maintenance 对外仍显示 idle

观察项真实含义不保证
status: runningdriver 已预留,可能在 pre-step、模型、工具、Turn 关闭或连续 Turn 之间某条消息正在执行;当前 Turn 一定仍接受 steer
status: idle没有 turn driver;也可能正在运行 maintenanceInbox 为空
whenIdle()等待当前活动及其在退休前替换出的活动稳定结束某个 MessageId 的结果
runMaintenance()从真正 idle 独占非 Turn 活动;waking input 被 latch向模型暴露 maintenance 自身
活动所有权

公共类型明确把 running 定义为完整 drain 区间,把 maintenance 保持为外部 idle;whenIdle 通过比较 activity Promise 跟随替换工作,而不是绑定某次 send。INBOX-ACTIVITY-CONTRACT

11. Cancel 要分“信号”“待处理输入”“已认领输入”三层看

调用当前活动未 claim Inbox已 claim batch
cancel(cause)若非 idle,abort先清 next-step、再清 next-turn,写 canceled splice;idle 时也会清安静停放的输入不恢复
cancel(cause,{keepInbox:true})若非 idle,abort保留,但 pre-abort 工作不会因此自动 wake不恢复
Web session.cancel调用上式 keepInbox保留普通队列与 steering不恢复
Subagent interrupt调用上式 keepInbox,立即返回保留,等待下一次 waking send不恢复
实现合同

默认 cancel 无条件调用 Inbox.clear,并只在非 idle phase 上 abort;keepInbox 跳过 clear。测试证明 waking send 可能在调用 cancel 前已经被 claim,此时 keepInbox 没有东西可保留;默认 cancel 则会丢弃 mid-step queued tail 和 steering。INBOX-CANCEL-CODEINBOX-CANCEL-BEHAVIOR-TESTINBOX-CANCEL-DROP-TEST

12. Abort-to-idle wake latch 修复了取消收敛窗口,但仍保留一个更窄的退休缺口

活跃 Turn 已 abort,但尚未 idle
  ├─ 此前已排队 + keepInbox → 保留但停放(没有 latch)
  ├─ 此后新的 waking send  → 强制 next-turn + wakeRequested=true
  ├─ 默认 cancel            → 清 Inbox + 清 latch
  └─ disposed               → 永不 latch

旧 driver finally:idle → 若 latch 且仍有 pending → 新 driver
为什么要重新分类

send 在插入前读取 abort 状态;post-abort 的 steer 也会改投 next-turn,避免加入一个已经注定结束的 Turn。收敛后的 replay 保证旧 turn/end 先于新 turn/start,移除被 latch 的消息会抑制空 replay。回归测试覆盖同 tick、慢收敛、maintenance、默认 cancel 与 disposed。INBOX-CANCEL-LATCH-TEST

13. Turn 记录本身不足以回答“这份工作去了哪里”

一个无 Step 的 Turn 可能是空 claim、listener 清空、pre-step reject、取消或错误;只看 turn/end 无法区分“什么都没做”和“消费了但没有进入模型”。foldConsumedWork 因而联合读取 step、纯删除 claim 与 canceled splice。INBOX-CONSUMED-WORK

历史形状归账
进入过 Step该 Turn 计为 consumed
claim 后 blocked / aborted / error该 Turn 计为 consumed
claim 后 completed 且无 Step视为 listener 清空,不把该 Turn当作完成工作
Inbox canceled 且无 replacementdroppedUnrun: true
一次 splice 用新消息替换旧消息仍 pending,不算 dropped
回归覆盖

专门测试覆盖 failed claim、stopped claim、blocked claim、empty rewrite、turn 外 claim、unrun drop、replacement 与后续 Turn 吸收旧 drop。INBOX-CONSUMED-WORK-TEST

14. “已写日志”“已发 Web 快照”“已落盘”是三个不同提交点

Host prompt accepted
  → Inbox splice 已同步 append 到 live Session
  → API 可返回 accepted
  → JSONL write-behind batch(默认固定窗口)

首个 pre-step checkpoint
  → flush enqueue + turn/start + claim
模型 adapter dispatch 前
  → flush step/start + user/message + request prefix
副作用前 fail-closed

checkpoint policy 在 pre-step 与 llm/stream 边界 flush;后者延迟构造下游 stream,flush 失败就不调用模型。顶层 tool dispatch 也采用同样策略。INBOX-CHECKPOINT-POLICY

硬崩溃实验

E2E 子进程在模型 dispatch 时被硬杀,恢复日志仍包含 insertion、turn/start、claim、step、user message 与 request prefix,并合成 interrupted closers;本次交付也重新运行该文件的 2 个用例并全部通过。INBOX-CRASH-RECOVERY

JSONL backend 把 live events 固定窗口合批,flush/teardown 会立即 drain;每批 append 与 fsync,失败回滚到原字节长度。由此可知 prompt 的同步 accepted 是 Session-level admission,不是独立的 fsync receipt;如果产品需要“回复前必须断电安全”,还需要显式等待 checkpoint。INBOX-JSONL-DURABILITY

15. Resume 会重建 pending,但不会仅因重建而自动开跑;fork seed 也不会复制祖先待办

恢复路径先从 persistence prepare Session,再走与新建相同的 prepare/setup/publish;新的 ReactLoopAgent 构造器从日志恢复最后 Turn 编号和 Inbox。INBOX-AGENT-RESUME

Seed ownership boundary

Inbox 只回放 session.events.slice(seedLength)。fork seed 中来自父 Session 的历史 splice 是孩子的上下文历史,不会重新变成孩子的 pending 工作;孩子自己在 seed 之后接受但未 claim 的消息则会恢复。INBOX-SEED-BOUNDARY

构造与 publish 本身不调用 wake。普通 Host 冷恢复通常紧接着处理一条 waking prompt;continuable child 冷恢复也紧接着调用 followup,所以旧 pending 会按两队列 claim 规则与新 wake 一起继续。单独恢复一个 Agent 并不承诺自动消费停放输入。

16. Ordinary Host API 返回 admission,不返回 Turn 或回复

session.prompt 接受 mode: queue | steer,处理时区、附件耐久化和 message source 后,分别调用 agent.followupagent.steer;同步入队成功即返回 {accepted:true}。它没有等待 pre-step、模型结果或 idle。INBOX-HOST-PROMPT

Wire contract

公开 contract 将 queue/steer 直接映射到两种 Agent 操作;updateQueue 支持 edit/remove/steer,而 session.cancel 明确保留 pending work。INBOX-HOST-CONTRACT

Host 会为 cold ordinary session 先解析/恢复 Agent;但直接 mode: steer 不额外检查 running,因此它仍服从底层的“空闲时由 steer 打开一个 Turn”。这与 UI 的保守 submission policy 是不同层的约束。

17. Queue edit/remove/strict-steer 以 MessageId 定址;claim 赢掉竞态

动作允许条件实现
Edit仍 pending;仅 text blocks深冻结 replacement,保留同一 MessageId
Remove仍 pendingcanceled splice 删除
Strict steer项目仍在 next-turn 且 Agent 当前 running先 remove,再用同一消息调用 steer
权威检查发生在突变时

Host 在两个列表中重新定位消息;若 loop 已先 claim,操作返回 queue-item-not-found。strict steer 的 running 检查比自由 mode: steer 更严格,但它不是绑定某个 Turn 的 capability token,仍受最终退休窄窗口约束。Subagent-owned Session 禁止这些 ordinary queue 操作。INBOX-HOST-QUEUE-MUTATION

18. Web 使用完整快照做 live mirror,再用 durable splice 恢复历史身份

Session splice(observer 看到 pre-state)
  → Host 对当前列表应用本次 splice
  → session/queue { complete items[] }
       next-turn              → queued
       next-step + user       → steering
       next-step + non-user   → context
Post-state projection

Host 显式投影两个列表,并用 source.kind 将 next-step 分成 steering/context;事件 observer 把当前发出的 splice 应用到 pre-state,避免等待 live Inbox mutation。INBOX-HOST-PROJECTION

重连基线

mux 先发 session/subscribed,仅在有 pending 时再发 queue baseline。客户端在 subscribed 上清空旧 generation,所以“没有后续 queue frame”正确表示空队列,不会把断线前的项目留成幽灵。INBOX-RECONNECT-HOSTINBOX-RECONNECT-CLIENT

Queue mirror 以完整 snapshot last-wins;pending steering 一旦出现相同 MessageId 的 durable user/message 就从瞬态镜像退休。历史 replay 则读取 next-step splice 的非 canceled removal,再检查 user source,把同一个 user/message 重建为 steering。INBOX-CLIENT-MIRRORINBOX-HISTORY-CLASSIFICATION

19. UI 把 queued 与 steering 放在两个位置,并把“快捷发送”定义成策略而非真相

QueueDock 只显示 placement=queued,提供编辑、删除和逐条 steer;steer 按钮只在 running 时启用。pending steering 则以用户气泡样式显示在 conversation tail,等 durable message 进入历史后完成 handoff。INBOX-QUEUE-DOCKINBOX-PENDING-STEERING-UI

Enter policy

非 running 或不支持 steering 的 transport 一律 queue;busy ordinary session 依据保存的 Queue/Steer 偏好选择,Cmd/Ctrl+Enter 取相反模式。空 draft 的加速 Enter 可把全部 queued rows 逐条 strict-steer;held Enter 被抑制,避免连续发送。INBOX-SUBMISSION-POLICYINBOX-INPUT-GESTURES

20. Continuable child 的 Activation 是 residency epoch,不是一次请求或一个结果

startContinuable 建立 durable child;后续 followup 隐藏 resident 与 cold-resume 差异,并把每条输入交给孩子自己的唯一 Inbox。成功边界是孩子接受 message id,不等待 Turn 开始,也不返回孩子回复。INBOX-CONTINUATION-SEAM

Activation state推导条件
runningChild Agent running,或 manager 已登记 waking message 但同步 send 尚未完成
waitingChild Agent quiescent,但仍拥有尚未释放的后代 Activation
settledAgent quiescent且没有 owned children;可以 dispose handle

一个 Activation 可以执行许多 FIFO Turn,也会因后代仍运行而在自身 idle 后继续驻留;manager 只拥有 residency/ownership,Turn 顺序仍完全由 Agent loop 与 Inbox 所有。INBOX-ACTIVATION-MODELINBOX-ACTIVATION-STATE

21. 父→子 delivery 永远是 follow-up;锁把 delivery 与 disposal 线性化

对 resident child,manager 在 child-id 锁内提交消息;若 disposal 已开始,就等待旧 Activation 释放后 cold-resume。对 absent child,它检查持久化 Session、先按 durable parent lineage 授权、只折叠 seed 之后的 continuable descriptor,再重建 Activation。INBOX-CONTINUATION-DELIVERYINBOX-COLD-RESUME

Acceptance fence

最终 admission 是无 await 的同步段:检查 caller signal、manager drain、Activation disposal 和 direct-parent authority;先把 MessageId 放进 accepted set,再调用 child Agent.followup,成功后标记 announced。每条父→子消息因此只进入 next-turn,不会 steer 孩子当前 Turn。INBOX-CONTINUATION-ADMISSION

Interrupt 不等于结束

interrupt 只在 live Activation 上授权并调用 cancel(...,{keepInbox:true}),立即返回;pending 与后代保留,claimed work 不恢复,absent target 是 accepted no-op。下一条 waking follow-up 才重新启动停放队列。INBOX-CHILD-INTERRUPT

22. 子→父有两种独立消息:主动 report 与自动 settlement account

消息来源父调度是否结束 child
Explicit reportsubagent-report relaywakeup → followupquiet → inject
Settlement noticesubagent-settled noticeparent closing → inject;idle → followup;busy → steer是 manager 对已结束 residency 的说明,不是 child 自述
主动报告

只有 exact resident child Agent 可以 report,收件人从 durable direct parent 推导而不能由 caller 任意指定;wakeup 与 quiet 是父调度策略,报告本身不改变孩子 Turn 或 Activation。INBOX-CHILD-REPORT

结算通知

manager 在 child handle 已释放、但父 ownership 尚未释放时投递 settlement,避免父 watcher 先把自己销毁。busy 父使用 steer,使同时完成的多个孩子在同一 next-step batch 合并;通知失败只记录并丢弃,最终 flush 也是 best-effort。INBOX-SETTLEMENT-NOTICE

23. Child 的 Web 与模型控制面有意比 ordinary Session 更窄

表面Follow-upSteerStop
Web continuable child要求 live direct parent;只接 text;返回 MessageId无,客户端忽略 mode 并走 subagent.prompt可在 parent offline 时按 durable address interrupt
Web one-shot child只读
模型 send_messageexact live direct parent 发给 child 的下一 FIFO Turninterrupt_agent 可由 live ancestor 中断 descendant

Wire contract 明确把 prompt receipt 定义为 Inbox MessageId,把 interrupt receipt 定义为信号被接受而非目标 quiescent;Host 的 child prompt 要求 live parent,interrupt 则刻意不查询 parent、catalog 或 persistence。INBOX-SUBAGENT-WIREINBOX-SUBAGENT-HOST

客户端把 one-shot 设为只读;continuable child 即使 running 也保留 Send 并单列 Stop,使新消息排为后续 Turn。parent offline 时输入禁用但仍允许停止 live child;模型工具同样说明 send_message 不返回回复、不能 redirect 当前工作。INBOX-CHILD-CLIENTINBOX-CHILD-COMPOSERINBOX-CHILD-OFFLINE-UIINBOX-CONTROL-TOOLS

24. 固定基线存在两处可复现的文档漂移

相邻文档/注释写了什么当前生产代码
api/events.tspending message 到 claim 才 durable;pending work 没有 durable Session eventagent/inbox/spliced 在 live mutation 前写入,插入事件携带完整消息
ApiProxy READMEpending next-step steering 不进入 Web projectionqueueItems() 投影两个列表;user-origin next-step 明确标 steering,ChatView 显示 pending bubble
不是解释差异

Wire 注释与当前 durable event schema/Inbox 实现直接矛盾;README 与当前 Host projection/UI 直接矛盾。这些判断来自同一固定 commit 的源码对照,不是对运行时的猜测。INBOX-WIRE-DOC-DRIFTINBOX-README-DRIFT

25. 保证矩阵与设计评价

命题保证不保证
普通 follow-up单一 Inbox 中 FIFO;成功 drain 时每条一个 Turn每条独立结果 Promise;遇到 reject/error 后自动继续
Steer最近尚未越过的 Step boundary;空闲时可 wake修改飞行中的 request;绝对无退休竞态
Inject下一次 claim 时作为 model-facing context自己 wake;一定属于当前 Turn
Cancel keepInbox保留尚未 claim 的输入恢复已 claim 工作;自动 wake pre-abort 队列
Prompt acceptance同步 Inbox/Session admission 与 MessageId(child)模型已调用、回复已产生、物理 fsync 已完成
Continuable child跨 residency cold resume;父→子永远下一 FIFO Turnsend_message 同步返回 child result;current-turn steer
最终评价

最强的设计点是没有为 UI、插件与 subagent 各造一套待办队列:两列表、一个 splice 词汇、稳定身份和同一 claim 算法贯穿内核、恢复和产品面。最昂贵的复杂度则集中在“接受不等于执行”的边界:pre-step 先 claim、cancel 不回滚、driver status 跨 Turn、Web snapshot 与物理持久化又各有自己的提交点。系统已经用 consumed-work fold、checkpoint 和 wake latch补齐大部分可观测性,但公开文档仍需纠正,若要更强的 no-stranding 或 crash-safe acknowledgement,还需新的显式协议而不是更乐观的文案。

验证状态

本章除逐分支追踪固定 commit 的生产源码、wire contract、README 和测试外,还实际运行了 13 个 Agent/Inbox/Host/client/subagent 定向 Vitest 文件(379 tests passed),并单独以 E2E 配置运行 hard-crash checkpoint 文件(2 tests passed):合计 14 files、381 tests 全部通过。

我的学习体会

内容仅自动保存到当前浏览器,不上传、不进入仓库。你可以导出 Markdown 自行归档。