/** * 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。 * * 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次, * 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在 * 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。 * * 两个平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。 */ /** * 一次补拉最多处理几封。 * * 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去, * 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。 * 超出的部分留在收件箱里,下次重启或人工触发时再处理。 */ export const MAX_CATCHUP = 5; /** * 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。 * * 补拉与 SSE 走同一条投递路径(deliverMail),因此形状必须一致 —— * 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。 * * @param {any} mail `/mail/inbox` 返回的一行 * @returns {{mail_id: string, session_id: string, from_name: string, * subject: string, mail_type: string, role: string, * to_workspace: string, catchup: true}} */ export function mailToEvent(mail) { return { mail_id: mail?.mail_id || '', session_id: mail?.session_id || '', from_name: mail?.from_name || '', subject: mail?.subject || '', mail_type: mail?.mail_type || 'normal', role: 'to', to_workspace: mail?.to_workspace || '', /* * 权限档位**必须在补投路径上带过来**(2026-09-14 修)。 * * 病根:SSE 的 `new_mail` 带 `permission_mode` / `permission_enforcement`, * 而这里只搬了 8 个字段 —— 于是 worker 的 * `mailContext.permissionMode = msg.data?.permission_mode || 'workspace'` * 落到默认 workspace。**只有补投的邮件中招**(SSE 实时送达的不受影响), * 表现为:一条 full 档会话里只要有一封补投邮件,worker 就去申请审批 → * 服务端按档位回 409 → 桥按「永久失败」当场 block → 这一轮 bash/write/edit 全被拦。 * * 字段名与 SSE 逐字一致(就是这个文件头声称的目标); * `GET /mail/inbox` 的行**已经**带这两个字段(服务端 `ListInboxScoped` 里有 * `JOIN sessions s` + `COALESCE(NULLIF(s.permission_mode,''),'workspace')`, * `models.Mail.PermissionMode` 的注释写明了补拉路径必须有它), * 是这里把它们丢掉的 —— 不需要动服务端载荷,也不需要插件另发一次请求。 * * 缺失时给空串而不是猜一个档(调用方 `|| 'workspace'` 自会兜底): * 这里猜等于替服务端做安全决策,猜宽了就是提权。 */ permission_mode: mail?.permission_mode || '', permission_enforcement: mail?.permission_enforcement || '', /* * 同一族的另外三个字段(2026-09-14 用"配对"扫出来的,不是猜的): * 把四个桥**读投递事件的字段**与 `mailToEvent` 产出的字段对了一遍, * 缺的邮件类字段就是这三个 —— 它们和权限档位是同一个根因(这个函数搬的字段太少)。 * * - `from_human`:消费方(dsh 的 adoptPrompt)靠它决定"回信不用你自己发"这句 * 说不说。缺了它,**人发来的信在补投路径上被当成 Agent 来信,失去自动回信** * —— 服务端 `models.Mail.FromHuman` 的注释早就写明"补拉路径必须有它"。 * - `in_reply_to`:SSE 那边等于 `ParentMailID`。缺了它,"这封是对我的回复" * 被当成"新派的活",两边会互相客套到撞上 hop 上限(生产实测 6 轮)。 * 行里的字段叫 `parent_mail_id`,这里**只做改名,不做推算**。 * - `session_alias`:会话的寻址名。缺了它,插件只知道 session_id, * 而 `send_mail` 不接受 session_id。 * * **没做的那个**:`reply_address`。它不在收件箱行里(服务端在 notify 的载荷里 * 现算:`FormatAddress(replyTo, "", alias)`,且是**按收件人**算的)。 * 服务端注释明确说"插件不必自己拼(拼错了就是静默开新会话)", * 所以这里不替它拼 —— 那是服务端补一个字段的事,留给上游决定(见回信说明)。 */ from_human: mail?.from_human === true, in_reply_to: mail?.in_reply_to || mail?.parent_mail_id || '', session_alias: mail?.session_alias || '', /* * `reply_address` 曾经是**唯一**一个收件箱行里没有的字段 —— 服务端在 notify * 的载荷里现算(`FormatAddress(replyTo, "", alias)`)。既然补投路径也需要它 * (dsh 桥的提示词读 `data.reply_address`),服务端就把它一并放进收件箱行 * (`models.Mail.ReplyAddress`,由 `ListInboxScoped` 填)—— 插件只搬运,不自己拼: * 服务端注释明确说"拼错了就是静默开新会话"。 */ reply_address: mail?.reply_address || '', // 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」 catchup: true, }; } /** * 从收件箱挑出该补投的邮件。 * * @param {any[]} mails `/mail/inbox?status=unread` 的结果 * @param {Set} seen 已经通过 SSE 投过的 mail_id(避免重复投递) * @param {number} [max] 上限,默认 MAX_CATCHUP * @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理) */ export function selectCatchup(mails, seen, max = MAX_CATCHUP) { if (!Array.isArray(mails) || mails.length === 0) return []; const picked = []; for (const m of mails) { const id = m?.mail_id; if (!id) continue; // 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、 // 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。 if (seen && seen.has(id)) continue; // permission 类邮件不补投:它是给人看的询问,Agent 侧没有可恢复的上下文 // (原来的工具调用早随进程一起没了),投过去只会让模型困惑。 if (m?.mail_type && m.mail_type !== 'normal') continue; picked.push(m); } // 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理, // 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。 picked.reverse(); return picked.slice(0, Math.max(0, max)).map(mailToEvent); }