oh-my-openagent Team Mailbox Fallback Wake:团队消息递送失败时的确定性唤醒回退机制解析
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
导读
本文以 oh-my-openagent(OmO)仓库中的 Issue 5317 验证证据文档为主线,系统讲解 Team Mode 下邮件信箱(mailbox)的 fallback wake(回退唤醒)机制:当团队消息无法通过实时通道递送时,系统如何通过确定性 prompt-gate 回退与信箱注入,保证消息不丢失、不重复、不扰乱正在运行的会话。读完本文,你将掌握 mailbox 的磁盘落地模型、<peer_message>注入信封、消费租约与确认(ack)语义,以及围绕"唤醒保留(hold)"与"过时唤醒取消(cancellation)"的完整回归验证方法论。
一、背景:为什么需要 fallback wake
在 oh-my-openagent 的 Team Mode 中,成员之间通过 team mailbox 传递消息,核心调用面由 packages/team-core/src/team-mailbox/index.ts 导出,包括sendMessage、listUnreadMessages、pollAndBuildInjection、ackMessages、reserveMessageForDelivery等原语。消息递送存在两条路径:
- 实时递送(live delivery):成员会话空闲时,消息通过 idle-injection 协调器直接"唤醒"(wake)会话并注入上下文;
- 回退递送(fallback delivery):实时递送被阻塞(例如接收方会话正处于活跃回合、prompt-gate 判定当前不该打扰)时,消息在信箱中排队,等待下一个可注入时机以"排队唤醒"(queued fallback wake)的形式投递。
Issue 5317 的验证目标正是这两条路径衔接处的确定性行为:排队中的 fallback wake 是否会被准时分发、是否会被过时取消、是否会被错误地重复分发。
二、Issue 5317 验证了什么
依据 .omo/evidence/20260828-issue-5317-team-message-fallback-wake/README.md,本轮验证覆盖三个层面:
- 确定性 prompt-gate 回退与信箱注入:在模块接缝(module seam)处以确定性方式驱动"保留空闲接收方门控 → 发送 → 确认未读 → 释放阻塞器"的完整链路;
- 聚焦与关联测试、类型检查与构建:对修复点做聚焦回归,同时对受影响的关联模块做全量回归;
- 真实环境验证:使用一次性(disposable)OpenCode 1.18.4 容器、CI-Bun 1.4.0 本地 bundle,以真实 Team Mode 运行并观察
team_create、team_send_message工具注册、SSE 连接与宿主会话状态。
三、测试过程与三轮红绿迭代
3.1 RED:排队 fallback wake 超时
第一轮(failing-first)暴露的缺陷记录在 verification.txt:
messaging.test.ts: timed out waiting for queued fallback mailbox wake corrected gate-blocker path: 1 failed即:当 prompt-gate 阻塞实时递送后,消息进入排队状态,但排队的 fallback wake 迟迟未被分发,测试等待超时。这是"预约(reservation)→ 唤醒(wake)"状态转换失败导致的。
3.2 REVIEW RED:二次递送与已确认消息的幽灵唤醒
评审(review)阶段又发现两类更微妙的缺陷:
- 第二次递送与已接受的实时 prompt 重叠:fallback wake 在接收方正在处理实时 prompt 时被再次分发,造成上下文重叠;
- 已确认消息仍分发其排队的 fallback wake:消息已被精确确认(exact-message ack),但排队中的唤醒仍被派发,形成"幽灵投递"。
这两类问题对应到源码中即是:唤醒分发必须尊重pendingInjectedMessageIds与已消费台账(consumed ledger),否则会造成同一消息多次进入接收方上下文。
3.3 GREEN:聚焦与关联回归全绿
修复后各门禁结果(最终 post-merge 数据,见 README):
| 门禁 | 结果 |
|---|---|
| focused messaging | 40/40 通过 |
| related messaging/idle-wake/prompt-route | 68/68 通过 |
| prompt gate core | 70/70 通过 |
| typecheck / build | 通过 |
| Senpi 包门禁 | 2419 通过,1 个 Windows-only 跳过 |
| 一次性 OpenCode 双次发送 | 完成且无 NDJSON 错误 |
3.4 真实服务器验证
在一次性 Docker 容器(OpenCode 1.18.4 + CI-Bun 1.4.0 本地dist/index.js)中:
- 健康检查:
healthy; team_create与team_send_message均已注册;- SSE
server.connected已连接; - 宿主会话数保持7933 → 7933(容器运行前后不变,证明 disposable 容器未污染宿主);
- 实时递送早期预约释放数为 0(live-delivery hold 被正确保留);
- fallback 在阻塞器过期后正确分发。
四、源码级机制:mailbox 的磁盘落地与唤醒分发
4.1 信箱目录结构与消息文件
信箱以文件系统为存储介质,路径由 packages/team-core/src/team-registry/paths.ts 的getInboxDir决定:
<base_dir>/runtime/<teamRunId>/inboxes/<memberName>/每个未读消息是<messageId>.json;消息进入递送预约后重命名为.delivering-<messageId>.json;确认消费后移动到processed/<messageId>.json。base_dir默认是~/.omo,也可通过配置覆盖。所有路径段经过resolveContainedPath校验,防止路径穿越(TeamPathTraversalError)。
消息结构由 packages/team-core/src/types.ts 的MessageSchema定义:messageId(UUID)、from、to、kind、body(上限 32KB)、timestamp、可选的summary、references、correlationId与color。
4.2 发送与背压控制
sendMessage(packages/team-core/src/team-mailbox/send.ts)在写盘前做多重校验:
- 广播(
to: "*")仅限 lead 角色,否则抛BroadcastNotPermittedError; - 负载超过
message_payload_max_bytes(默认 32768 字节)抛PayloadTooLargeError; - 团队处于 deleting/deleted 状态抛
TeamDeletingError; - 收件人未知或不活跃抛
InvalidRecipientError; - 未读信箱总字节超过
recipient_unread_max_bytes(默认 262144 字节)抛RecipientBackpressureError(背压); - 消息 ID 已存在(未预约或已预约)抛
DuplicateMessageIdError。
写盘通过withLock(<inboxDir>.lock)串行化,保证并发写者互不覆盖——这一点由 send.test.ts 的"4 个并发写者写入 4 个不同文件"用例验证。值得注意的是,对于预约中的收件人,消息会直接以.delivering-预约形态落盘(reservedRecipients分支),这正是"预约 → 唤醒"转换的起点。
4.3 轮询与注入:<peer_message>信封
packages/team-core/src/team-mailbox/poll.ts 的pollAndBuildInjection是唤醒分发的前置环节:它列出未读消息,排除已在pendingInjectedMessageIds中的消息,并将每条消息序列化为<peer_message>信封(buildEnvelope),携带from、timestamp、messageId、kind、correlationId等属性。同时通过运行时状态机保证:
- 同一 turn marker 内不重复注入(
lastInjectedTurnMarker); - 存在 pending 消息时返回
reason: "pending ack",避免在确认前重复投递。
MessageSchema中消息正文上限 32KB,与发送侧的message_payload_max_bytes默认值一致,构成端到端约束闭环。
4.4 唤醒协调器:IdleInjectionCoordinator
真正的 wake 由 packages/omo-senpi/src/extension/idle-injection-coordinator.ts 的IdleInjectionCoordinator承担。它是父会话的单一注入队列:任务完成、团队消息、DAG 运行摘要、ulw 续跑等通知全部入队,在批次窗口内合并为恰好一次omo-senpi:wake注入(customType: "omo-senpi:wake"),再以steer或followUp方式送入运行中的回合。
关键设计:
- 确定性排序:
SOURCE_RANK规定了混合批次中先注入哪些来源——任务完成最先、DAG 摘要最后、team-message排在第 1 位; - 回执契约:已接受的注入必有且仅有一个回执——成功触发
onFlushed,失败触发onDeliveryFailed;被拒绝(协调器已退休)则无回执,由生产者自行走持久化失败路径; - 退休即取消:会话关闭时
retire()取消已武装的批次窗口定时器、清空队列并给所有待注入项发失败回执,保证任何回调都无法触达已失效的生成器(issue #7932 的教训)。
这条源码脉络印证了 README 中"保留已接受的实时递送 hold、仅在阻塞器过期后分发 fallback"的修复目标:fallback wake 必须经由协调器排队,而不是绕过批次窗口直接投递。
4.5 消费租约与确认
- 消费租约:consumer-lease.ts 的
withInboxConsumerLease通过.consumer.lock租约文件串行化同一信箱的消费方,支持staleAfterMs过期回收(死进程租约可被立即重获,见 consumer-lease.test.ts); - 确认:ack.ts 的
ackMessages将未读文件或预约文件原子地rename到processed/,且对已确认的消息重复确认幂等(ack.test.ts 验证了两次 ack 不报错、不残留); - 消费台账:consumed-ledger.ts 的
isMessageConsumed通过检查processed/<id>.json是否存在判定消息是否已消费。
这三者共同支撑了"exact-message ack 后取消过时唤醒"的修复:一旦消息落入processed/,任何排队的 wake 都应被判定为过时并取消。
4.6 预约回收与待交付恢复
packages/team-core/src/team-mailbox/reservation.ts 定义了递送预约的三态文件迁移:<id>.json(未读)→.delivering-<id>.json(预约中)→processed/<id>.json(已消费)。reclaimStaleReservations会把超过staleTtlMs的预约文件恢复为未读文件,防止预约永久卡死。
packages/team-core/src/team-mailbox/pending-delivery-recovery.ts 则提供两类关键恢复原语:
findDeliveredMessageIds:通过client.session.messages读取接收方会话历史,检测<peer_message messageId="...">信封是否真的进入了上下文。未返回的消息意味着"wake 被接受但从未进入上下文",此时 ack 会静默丢消息,因此必须重新排队而非确认——任何读取错误都返回空集合(loss-safe 答案,调用方重排而不是 ack);requeuePendingLiveDeliveries:把确认未达的预约消息恢复为未读文件,供下一轮 poll-injection 或 wake-hint 重新投递。
这两条路径正是 README 中"保留 filesystem errors、不把不可读信箱条目变成虚假确认"的实现依据。
五、为什么"这样就够了":修复清单背后的验证推理
README 的 "Why it is enough" 部分逐条说明了各回归用例所覆盖的缺陷面,结合源码可以对应到具体机制:
| 回归覆盖点 | 对应机制 |
|---|---|
| 失败的"预约 → 唤醒"转换 | reservation 三态迁移 +pollAndBuildInjection的 pending 过滤 |
| 保留已接受的 prompt hold | 协调器批次窗口 +lastInjectedTurnMarker防重 |
| exact-message ack 后取消唤醒 | ackMessages迁移到processed/+ 消费台账判定 |
| ack 旧的可合并消息不 strand 新消息 | 消息级去重:按messageId独立处理,非按批次 |
| transient 校验错误排队重试而非取消 wake | 协调器的onDeliveryFailed回执走持久化失败路径 |
| 团队状态缺失时取消过时条目 | sendMessage的TeamDeletingError/ runtime 状态检查 |
| pending ack 的消息取消冗余 queued wake | pendingInjectedMessageIds去重 |
| 分类的发送前连接失败保留 fallback 队列条目 | wake 接受后才清理队列项 |
| 可重试队列失败最多 3 次 | capped retry(三上限) |
| 预约槽读取保持可见 | .delivering-文件在getUnreadSizeBytes中被计入 |
| 重试分类不再清除保守分发 hold | 修复 REVIEW RED 的"重叠分发"问题 |
| fallback wake 采用 capped exponential backoff | 替代全局丢弃策略,延迟的持久条目轮转在无关 prompt 之后 |
| 明确的 host/network 不可达与连接超时进入 durable retry | 连接分类器(connection classifier)判定 |
| 无资格的 fetch 错误不进入重试路径 | 因可能发生在 OpenCode 已接受请求之后,只有错误本身或其 cause 上的明确连接码可重试 |
| exact-message 读取保留文件系统错误 | inbox.ts 的readUnreadMessageById:读取失败抛错而非返回 undefined |
最后一条值得展开:readUnreadMessageById会依次尝试未读路径与预约路径,读取过程中若出现 ENOENT 之外的真实错误(如权限问题),会记录team-mailbox-exact-message-read-failed并重新抛出,而不是吞掉错误返回"未找到"。这保证了"不可读的信箱条目"绝不会被误判为"消息已读/可确认",与 README 中"Exact-message reads preserve filesystem errors instead of turning an unreadable inbox entry into a false acknowledgement"完全对应。
六、验证证据的组织方式
本仓库将验证证据沉淀为两层:
- 主证据:.omo/evidence/20260828-issue-5317-team-message-fallback-wake/——包含 README(测试范围、观察结果、充分性论证、省略项)、manual-qa.txt(确定性模块表面与真实 OpenCode 表面的逐项检查清单)、verification.txt(failing-first 与修复后的数值门禁);
- Senpi 适配器子证据:.omo/evidence/omo-senpi-adapter/20260828-issue-5317-team-message-fallback-wake/——包含 live-driver.json(真实 Senpi 驱动结果为
PASS、ultrawork 注入被观测到、comment checker 通过、真实 agent 目录未被触碰)、package-gate 与 self-test 记录。
文档同时明确记录了省略项:凭据、宿主配置、请求头与含密钥的日志;未做完整 provider turn(确定性竞态在模块接缝处覆盖);完整套件中无关的 OAuth 回调失败以 scoped gates 记录替代。这种"测了什么、为什么够、省了什么"的三段式证据结构,本身即是一种可复用的回归验证模板。
七、如何复现与深入
7.1 运行 mailbox 相关测试
仓库使用 bun 作为测试运行器,可在仓库根目录执行聚焦测试:
# 聚焦 team-mailbox 全模块测试 bun test packages/team-core/src/team-mailbox # 聚焦消息发送/确认/轮询注入 bun test packages/team-core/src/team-mailbox/send.test.ts bun test packages/team-core/src/team-mailbox/ack.test.ts bun test packages/team-core/src/team-mailbox/poll.test.ts # idle 唤醒协调器 bun test packages/omo-senpi/src/extension/idle-injection-coordinator.test.ts bun test packages/omo-senpi/src/components/memory/kibitzer/delivery-idle.test.ts # 类型检查与构建 bun run typecheck bun run build7.2 关键配置项
Team Mode 的 mailbox 行为由 packages/team-core/src/config.ts 的TeamModeConfigSchema控制,常用项及默认值:
| 配置项 | 默认值 | 说明 |
|---|---|---|
enabled | false | 是否启用 Team Mode |
base_dir | ~/.omo | 团队运行时数据根目录 |
message_payload_max_bytes | 32768 | 单条消息负载上限(与MessageSchema.body上限一致) |
recipient_unread_max_bytes | 262144 | 单收件人未读信箱背压上限 |
mailbox_poll_interval_ms | 3000 | 信箱轮询间隔(最小 500ms) |
max_parallel_members | 4 | 最大并行成员数(1~8) |
max_members | 8 | 团队最大成员数(1~8) |
max_messages_per_run | 10000 | 单次运行最大消息数 |
max_wall_clock_minutes | 120 | 单次运行最大墙钟时间 |
max_member_turns | 500 | 单成员最大回合数 |
7.3 真实环境验证的复现前提
真实服务器验证依赖一次性 OpenCode 容器(OpenCode 1.18.4)与 CI-Bun 1.4.0 本地 bundle,验证目标是"工具面(tool surface)真实验证 + 模块接缝确定性覆盖"的组合:容器化运行证明 shipped 工具面可用,模块级测试证明 hold 保留与过时唤醒取消。复现时注意保持宿主 OpenCode 会话数与容器运行前后一致(本证据中为 7933),以确认 disposable 容器不产生宿主副作用。
八、结论
Issue 5317 的修复与验证为 oh-my-openagent 的 Team Mode 消息递送建立了明确的正确性契约:
- 不丢:wake 被接受但未进入上下文的消息必须重新排队(
pending-delivery-recovery),不可读的条目保留错误而非虚假确认; - 不重:同一 turn marker、同一
pendingInjectedMessageIds、已消费台账三重去重,配合协调器的单次注入契约; - 不扰:fallback wake 尊重实时递送 hold,仅在阻塞器过期后分发;过时唤醒(消息已 ack、团队已删除、协调器已退休)一律取消;
- 可恢复:可重试失败走 capped exponential backoff 的 durable 重试路径,明确连接类错误才可重试,无资格错误不进重试。
这套从磁盘文件状态机到唤醒协调器、再到三层证据验证(failing-first → review 修正 → post-merge 重跑)的完整链路,为后续 Team Mode 消息可靠性相关的改动提供了一个可直接参照的回归基准。
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考