收件箱、团队与进度检查
持久化的 inbox 表承载后台 actor 的通知、权限转发与追加指令:写入与唤醒、排空成合成消息、渲染格式,以及残存的 Team 模块与进度日志检查。
上一篇的 spawn 立即返回,子智能体在后台跑完,结果得有路回到父级;Orchestrator 的子会话要请求审批;父级还要给已经闲下来的子智能体追加任务。MiMo Code 给这些消息修了同一条路:一张持久化的 inbox 表。发送方只管写一行并叫醒收件方,收件方的主循环每次迭代开头把收件箱排空,变成一条合成的 user 消息。上游 v1.14.19 的 packages/opencode/src 下没有对应的模块。
怎么用
- 模型发消息:
actor send <to_actor_id> "<content>" [--session <id>] [--type <t>],收件方可以是同一会话的子智能体(如explore-1)、某个会话的main,或用--session指定的别的会话(packages/opencode/src/tool/actor.ts:577)。收件方不存在时不抛错,而是返回inboxID为 null 并附receiver not found。Orchestrator 另有session send,见下一篇。 - 模型收消息:普通消息被包成
<inbox from="发送会话:发送者" sent_at="…">;运行时生成的通知是一段<actor-notification>,两者都作为 user 消息出现在下一次迭代里。actor send填的发送者是调用方的 agent 名(actor.ts:590),不是 actor ID。 - 用户看到的:后台子智能体完成、失败、取消、停滞都会弹 toast;开了 Orchestrator 实验开关时,TUI 还会把
<actor-notification>还原成一张卡片(packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1618)。 - 清理:没有配置项;Inbox 服务初始化时删除 7 天前的行(
packages/opencode/src/inbox/inbox.ts:18、inbox.ts:140)。
表与服务
inbox 表(packages/opencode/src/inbox/inbox.sql.ts:5)以 ulid 为主键,收件方是 receiver_session_id 加 receiver_actor_id,发送方两列可空,type 默认 text,正文以 JSON 存成带 text 字段的对象,外加 created_at。索引建在“收件会话、收件 actor、id”上(inbox.sql.ts:21),ulid 按时间排序,排空时按 id 取出,大体就是到达顺序。Inbox 服务只有 send 与 drain 两个方法(inbox.ts:120)。
消息从哪来
| 来源 | type | 收件方 | 位置 |
|---|---|---|---|
| 后台 actor 首轮结束(完成或失败),checkpoint-writer 除外 | actor_notification | 父会话里的 parentActorID,缺省 main | packages/opencode/src/actor/spawn.ts:429 |
| 后台 actor 被取消 | actor_notification | 同上 | spawn.ts:912 |
| 停滞看门狗 | actor_notification | 同上 | spawn.ts:990 |
| 被唤醒的 peer 跑完一轮 | actor_notification | 父会话 | packages/opencode/src/session/prompt.ts:4895 |
| Workflow 运行结束 | actor_notification | 所在会话的父 actor | packages/opencode/src/workflow/runtime.ts:1419 |
| 子会话的权限请求被转发 | text | 父会话的 main | packages/opencode/src/permission/index.ts:417 |
actor send | 默认 text | 任意已登记的 actor | actor.ts:585 |
session send,以及 session create --topic 命中已有子会话 | text | peer 子会话 | packages/opencode/src/tool/session.ts:893、tool/session.ts:752 |
取消与停滞通知只在对象是后台 subagent 或 peer 时发出,并排除 checkpoint-writer、dream、distill 这些运行时派生的智能体。Workflow 的通知虽然 type 也是 actor_notification,正文却是 Workflow completed. run_id: … 这样的纯文本,没有 <actor-notification> 外壳。
send:写一行,再叫醒
send 先做存在性检查(inbox.ts:145):收件方必须在 actor_registry 里有行,否则失败为 InboxReceiverNotFound。所以 peer 要在 session create 返回前同步登记,每个会话自带的 main 行也在这里派上用场。随后插入、发布 inbox.arrived 事件,再 fork 一个唤醒(inbox.ts:178):
const promptRef = sessionPromptRef.current
if (promptRef) {
yield* promptRef
.loop({
sessionID: input.receiverSessionID,
agentID: input.receiverActorID,
// Woken turns notify their parent on completion. The spawn turn goes
// through SessionPrompt.prompt (no flag) so forkWork.notify remains
// the sole notifier for turn 1 — no double-notify.
notifyParentOnComplete: true,
})
.pipe(Effect.ignore, Effect.forkIn(scope))唤醒 fiber 挂在 Inbox 服务自己的作用域里,发送方返回后照样送达。sessionPromptRef 是一个模块级的后绑定引用(packages/opencode/src/inbox/inbox-ref.ts:29),由 SessionPrompt 初始化时填入(prompt.ts:5326):主循环每次迭代都要调 inbox.drain,Inbox 若再以普通 Layer 依赖 SessionPrompt 就成环了。同样的手法还有 inboxServiceRef(actor 与 session 工具借它拿 Inbox,inbox-ref.ts:65)和 defaultModelRef(inbox-ref.ts:51)。
SessionPrompt.loop 按 sessionID:agentID 找运行器(prompt.ts:4923):空闲就开跑一个新的主循环;已经在跑,就只等待当前这次运行(packages/opencode/src/effect/runner.ts:114),新行留在表里,等下一次迭代开头排空。
有一点要注意:把 actor 标成 running、结束后写回结局的,只有上一篇的 runTurn(packages/opencode/src/actor/turn.ts:16),而经收件箱唤醒的轮次走 SessionPrompt.loop,不经过它。按代码读,一个已经 idle 的 actor 被唤醒后,注册表状态在这一轮里不会翻回 running:停滞看门狗不会盯它,session status 与 session join 看到的仍是上一轮的结局。
drain:变成一条合成 user 消息
主循环每次迭代开头、读取本切片历史之前,先排空自己的收件箱(prompt.ts:3624)。drain(inbox.ts:201)的步骤:
- 按 id 顺序取至多
MAX_DRAIN_PER_TURN即 100 行(inbox.ts:19),一行没有就立即返回。 - 决定合成消息挂在哪个 agent、用哪个模型(
resolveDrainSeed,inbox.ts:64):先找最近一条真实的、带模型的 user 或 assistant 消息,普通收件方只在自己切片里找,peer 跨切片找;找不到就用注册表行里的 agent 加项目默认模型;仍然没有则不写消息,行留在表里等下次。 - 逐行渲染,渲染出空白的行丢弃并记日志;全部空白就只删行、不写消息(
inbox.ts:261、inbox.ts:277)。空白的 user 文本部件会被 AI SDK 过滤掉,发给提供方时变成空内容而被拒。 - 写一条
agentID为收件方的 user 消息,每行一个synthetic: true的文本部件(inbox.ts:297),然后删除这些行。写消息与删行不在一个事务里,注释承认崩溃在两者之间会让下次重复注入同样的通知(inbox.ts:289)。
写入的是新的 user 消息,主循环于是把它当作本轮输入继续跑;这也是“闲下来的子智能体收到 send 就接着干”的全部机制。
渲染成什么样
renderInboxRow(packages/opencode/src/inbox/render.ts:13)只分两类:actor_notification 的正文由发送方预先渲染,原样放行;其余类型一律包成带发送者与时间的 <inbox> 元素(render.ts:24):
const sender = row.sender_session_id
? `${row.sender_session_id}:${row.sender_actor_id ?? "?"}`
: "system"
const sentAt = new Date(row.created_at).toISOString()
return `<inbox from="${sender}" sent_at="${sentAt}">\n${blankTo(content.text, "(empty)")}\n</inbox>`转发来的权限请求只填了发送会话,所以显示为 from="子会话ID:?",正文是一段 <permission-request>,告诉 Orchestrator 用 session approve 或 session grant-approval 处理。
通知正文由 renderActorNotification(render.ts:31)生成,首行固定以 Background sub-session "描述" (actor_id: …) 开头,接下来的措辞按情形变化:
| 情形 | 首行结尾 | 后续行 |
|---|---|---|
| 完成,自报 success、partial 或没报 | completed. | 有自报时的 Status:,以及 Summary:、Result: |
| 完成,自报 failed 或 blocked | finished (status: failed). 之类 | Summary:、Result: |
| 完成,自报值无法识别 | ended (status not reported). | Summary:、Result: |
| 失败 | failed. | Error: |
| 停滞 | appears stalled (no activity for Ns). | 建议查看、催促或取消 |
| 取消 | was cancelled. | — |
之所以区分“进程正常结束”和“任务是否成功”,是为了不让父级把一次自报失败读成完成(render.ts:47 的注释)。TUI 用反向的 parseActorNotification(render.ts:101)还原出状态、描述与一行摘要。render.ts:86 起的注释说 stalled 留给将来的看门狗、目前不会产生,已经过时:看门狗早就在发(spawn.ts:990)。
唤醒后的回报
forkWork 的通知只包住 spawn 那一轮。持久的 peer 闲下来后被 send 唤醒,跑完新的一轮时,主循环在结尾检查 notifyParentOnComplete:本次是收件箱唤醒的、会话有父会话、自己是后台 peer 且不是系统智能体,就用同样的格式给父会话投一条完成或失败通知(prompt.ts:4883)。首轮不带这个标志,不会重复通知;Orchestrator 因此不必轮询子会话。
条件里写死了 actor.mode === "peer",还要求会话有 parentID。按代码,同会话的 subagent 被 actor send 唤醒后跑完的那一轮不会通知父级,这与 packages/opencode/src/tool/actor.txt:126 对 send 的说明不符:
The woken turn's result reaches you as a completion notification, not from the
sendcall.
团队:只剩一个空壳
packages/opencode/src/team/index.ts:13 定义的 Team 服务只有五个方法:create、addMember、removeMember、getMembers、teamDir,数据是项目目录下 .mimocode/teams/<团队 ID>/members.json 里的成员数组(team/index.ts:36),成员记录会话 ID、agent、角色与加入时间。事件定义了 team.created 与 team.member.joined(packages/opencode/src/team/events.ts:5、team/events.ts:13),但只有前者在 create 里发布(team/index.ts:69),addMember 并不发后者;packages/opencode/src/team/schema.ts:7 的 TeamMessage 在 src 里也没有使用者。
源码里除了图层装配(prompt.ts:5367、packages/opencode/src/tool/registry.ts:547)找不到任何调用方。测试 packages/opencode/test/team/migrate-to-inbox.test.ts:97 断言 tool/team.ts 已删除,migrate-to-inbox.test.ts:107 断言 sendMessage、checkMessages、readState、writeState 不再存在:团队间收发消息那一套已经迁到收件箱,剩下的成员名册暂时没有入口。
进度检查:两种“盯”法
运行中靠上一篇的停滞看门狗:后台子智能体静默超过 6 分钟,父级收到一条 appears stalled 通知。
结束时靠内置插件 SubagentProgressCheckerPlugin(packages/opencode/src/plugin/subagent-progress-checker.ts:102,登记在 packages/opencode/src/plugin/index.ts:156 的内部插件列表里)。它挂在 actor.postStop 上,检查绑定了任务的子智能体有没有留下进度日志:
- 匹配范围:用
excludeOnly排除 checkpoint-writer、title、summary、dream、distill、compaction、main(subagent-progress-checker.ts:113)。默认 matcher 会跳过 general、explore 这些内置类型,而它们恰恰是最常绑定任务的子智能体,所以这里特意改写。 - 何时跳过:spawn 时没传
task_id(subagent-progress-checker.ts:127);agent 自身规则禁止写入,canWrite为 false(subagent-progress-checker.ts:132,由spawn.ts:472按 agent 权限算出);配置了memory.disable_write: true,这时写入会被路径护栏拒绝,催也没用(subagent-progress-checker.ts:139)。 - 检查什么:文件在
<数据目录>/memory/sessions/<会话 ID>/tasks/<TID>/progress.md(packages/opencode/src/session/checkpoint-paths.ts:84),必须包含 5 个标题## §1 Task identity到## §5 Outcome and discoveries(subagent-progress-checker.ts:11)。 - 不合格:文件不存在或缺标题时,返回
continue并附上完整模板作为理由,子智能体据此再跑一轮去补写(subagent-progress-checker.ts:151、subagent-progress-checker.ts:157),至多 3 次。 - 合格:插件往文件头写入
written-at时间戳的 frontmatter(subagent-progress-checker.ts:164),不再要求重跑。这份日志随后由检查点写手读取、并入会话检查点,见 检查点写手 与 树状任务。
缺文件时,催促的理由以这一句开头(subagent-progress-checker.ts:48):
Before terminating, write the task progress journal to:
下一篇:Orchestrator:一个会话管所有会话——把 peer 子会话、收件箱与权限转发组合起来,用一个会话调度所有工作。