发件箱与看门狗
从事故到设计(2026-10-02)
2026-10-02 的真实事故:一条 codex 线程忙了整个下午,滞留它的消息越堆越多。当时的补投循环很努力——每轮对队首消息重试到预算耗尽,全花在必败的投递上(线程还是忙的),队列尾部的消息 12 小时没轮到一次尝试。努力错方向的系统比懒惰的更糟。
这次事故定了三条设计律,今天的发件箱全部由此而来:忙是线程级状态(队首失败即整线程本轮放弃,不烧预算);新消息入队不插队(有人排队就不许直投钻空窗);重投要有专门的生命周期(不是每次命令顺手试一下,而是独立循环盯到投完)。
park 的那一刻
投递撞上忙等超时或写者锁(旧 daemon 世界)时,send 做四件事、然后正常退出(parked 是 exit 0 的合法结局):
① 组装好的信封逐字节落盘 ~/.crosschat/outbox/<线程id>.json(机器形态队列本体)
② 人类可读镜像同步追加 ~/.crosschat/mailbox/<线程id>.md(寄存原文 + 队列位次)
③ 分离进程拉起看门狗 单实例锁防重复;已在跑则什么都不做
④ 回执两行 parked + 队列深度 + mailbox 路径(人随时能读滞留原文)
寄存的是组装完成的信封原文——重投时不重新解析、不重新组装,原样再送。这一点是纪律:重投的是当初那条消息,不是“类似的一条“。
队列纪律
- FIFO 不插队:队列非空时,新 send 直接入队而不尝试直投——直投可能钻进补投刚好错过的空窗,破坏顺序。
- 每线程 200 条上限:到顶新消息被诚实拒绝(OUTBOX_FULL,报错直接给 mailbox 路径取回内容)——队列不该是无限黑洞。
- 逐线程锁文件:补投循环与命令入口的补投并发时,靠独占创建的锁文件互斥;持有者崩溃的锁 60 秒后被偷走,不会死锁。
- 限流一视同仁:补投也要过同一个 30 条/60 秒窗口(按寄存时的发送方身份计数);窗口满的条目本轮跳过、原样保留下轮。补投本身不写账本——只读检查,不消耗窗口。
看门狗的一生
park 触发 spawn(分离进程,跑 crosschat __drain --watch——隐藏维护入口)
│ 写单实例锁(pid+心跳时间戳,90 秒新鲜度)
▼
┌─► 一轮补投(每轮预算:5 条投递 / 15 秒墙钟 / 5 秒忙等)
│ ├─ 全部投空 → 删锁,进程退出(发件箱空 = 无人值守)
│ └─ 仍有滞留 → 心跳刷新,退避等待:
│ 连续空手 0→1→2→…轮:30s → 60s → 120s → 300s(封顶)
│ 任何一次投递成功 → 退避归零重来
└───┘
两条不变量值得注意:无常驻承诺——看门狗只在有活干时存在,队列空了进程就退(下次 park 再拉起);绝不打扰——所有输出只进自己的 stderr,一行“补投 N 条“,失败也不外溢。命令入口(send/status)每次也做一轮小预算补投,是机会主义 relief;看门狗是负责到底的那一层。
死信与自愈
- 死信:对端线程已删除(CODEX_THREAD_NOT_FOUND)时,该线程整队丢弃——消息永远无处可投,囤着没有意义;mailbox 记一行“线程已不存在,丢弃 N 条“。
- 损坏自愈,但两条腿策略不同:park 碰上损坏的队列文件按空处理(当场覆盖重写,弃旧数据);补投碰上损坏文件跳过且不删(留给下一次 park 自愈)。取舍一致:损坏是异常态,两腿都不让异常拦住正常路径。
- 投递失败的账目:parked 与补投成功各记一笔账(send-log 永不记正文),所以
status --conversations的滞留数与账本对得上——状态可核对,黑盒不成立。