feat(adopt): 邮件可投进平台上已存在的会话(TUI 与邮箱同一入口)

人在平台界面(pi TUI / opencode / DSH GUI)里开的会话,此前无法被邮件投进去。
补全早就把它们列为候选(agent_platform_sessions 镜像,插件心跳上报),
但投递侧的 FindNamedSessionFor 只查 sessions 表 —— 选中后只能得到 404。
候选列表在承诺一件做不到的事。

TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。

## Gateway

sessions 表加 platform_id 列 + 部分索引。resolveTarget 的 SessionNamed 分支
本侧查不到时再查镜像,命中则「接管」:本侧建一条会话并绑定 platform_id,
之后每次投递都在 SSE 事件里带 platform_session_id。

- FindPlatformSession(agent, slug, workspace) 查镜像
- FindSessionByPlatformID 防重复接管(一条平台会话只能被接管一次,
  否则同一条对话在邮箱里裂成多条互不相干的线索)
- AdoptPlatformSession 建会话 + 绑定 + 别名复用平台 slug(撞名自动加后缀)
- PlatformIDOf 供 notifyRecipients 读

三处语义决定:
- workspace 以平台会话为准(它的 cwd 创建时就定了)。地址 path 位不同则不命中,
  否则邮件会投进另一个项目的会话
- 主题优先用平台侧标题(它代表整条对话在谈什么,也是补全里显示的)
- 接管计入 AllowNewSession 速率限制 —— 镜像里可能有几百条 slug,
  不计的话它是绕过限流的后门

## 插件

字段解析与失败话术抽成共用模块 lib/adopt.js(三方逐字节相同 + 进同源校验):
字段名各写一遍时少个下划线就静默退化成「每封邮件新开一条」,而那个错误不抛异常。

- opencode:session.get 确认存在 → 照常 promptAsync(服务端持有会话,单一写者)
- DSH:复用 startAgent 的 resume 分支,会话 id 换成平台自己那个;
  界面上正开着时直接 followup(两个 handle 会各自写日志,replay 过不去)
- pi:SessionManager.open(file) → 跑一轮 → dispose,不放进长期缓存

pi 必须短暂持有:SDK 无任何锁机制(flock/lockfile 命中 0),活着的
SessionManager 不 watch 文件 —— 外部追加的行看不见,算出的 parentId 指向
对方不知道的 entry,会话树分叉。写入是纯 append 所以文件不会坏。
配套三处:isStreaming 时不释放(否则杀掉排队中的下一封)、兜底计时器
(轮次超时 ×2,unref)、接管会话跳过命名同步。

最后一条是实测撞出来的:别名撞名时 Gateway 加后缀,而定稿别名又回写进 pi
会话文件 → 下次心跳上报的 slug 变成带后缀那个,人从补全里选的名字凭空消失。
opencode/DSH 无此环(它们的 slug 只读不写)。

接管后必须加入 mailDriven 集合,否则邮件投进去了却永远没有回音。

## 迁移顺序

idx_sessions_platform 不能写在 init_sqlite.sql 里:那个脚本在
addMissingColumns 之前执行,而已部署的库里 sessions 表已存在
(CREATE TABLE IF NOT EXISTS 不补列)→ 索引建在不存在的列上,
整个迁移中断、服务起不来(生产实测)。依赖补出来的列的索引一律放
migrate.go 的 sqliteAddIndexes。PG 侧用 ALTER TABLE ADD COLUMN IF NOT EXISTS。

## 生产验证

- pi × 2(agent-only-chain / mail-probe-alias)、opencode(glowing-moon)、
  dsh(查看工程与插件适配指南)四条链路接管成功
- dsh 那次回信准确说出了界面上聊过的内容 → 上下文确实装回来了
- 第二封复用同一条本侧会话,平台侧无新增改名条目
- 回归:opencode 普通 .new + 别名续谈 + used_rounds=0(免配额通道未受影响)

## 其他

pi-mail-bridge 补 systemd 单元(此前是 setsid 裸进程,重启机器不会拉起):
陈锁清理 ExecStartPre、MemoryMax=4G、TimeoutStopSec=10。
配置目录必须与 opencode 分开(共用会让后起的读到对方密钥或撞单实例锁)。

PLUGIN-CONTRACT.md 加 B-3.7 / B-3.8 + new_mail 字段表 + 检查清单验收项。

测试:repo +10 例(adopt_test.go);三插件各 +7 例(adopt.test.mjs)
This commit is contained in:
2026-09-04 11:14:44 +08:00
parent e4052f8e84
commit 255c799a40
20 changed files with 1176 additions and 22 deletions

View File

@ -33,6 +33,7 @@ import {
noteExplicitSend,
shouldSkipAutoRelay,
} from "./lib/relay-dedup.js";
import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js";
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
// opencode 原生支持三态权限免批由它自己记response:"always"
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
@ -599,8 +600,13 @@ const pendingPermissions = new Map(); // permission.id -> { sessionID, callID }
// 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。
const relayedSummaries = new Map(); // opencode session id -> assistant message id
// 收到邮件后建立的会话,才需要在 idle 时把总结转回去。
// 用户在 TUI 里自己开的会话不该被搬进邮件系统。
// 哪些会话参与邮件往来,idle 时把总结转回去。
//
// 两个来源:① 收到邮件后新建的会话 ② **被接管的平台会话**(人在 TUI 里开的,
// 但已经有邮件投进来了)。后者从接管那一刻起加入 —— 不加的话邮件投进去了
// 却永远没有回音,发件人只看到信发出去后再无音讯。
//
// 没有邮件投进来的 TUI 会话不在这里,它们不该被搬进邮件系统。
const mailDrivenSessions = new Set(); // opencode session id
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
@ -612,6 +618,41 @@ async function resolveSessionForMail(client, directory, data, kind) {
const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
if (bound) return { sessionID: bound, reused: true };
// 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 TUI 里开的
// 那种)—— 投进它而不是新建。
//
// TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界:人在界面上聊了
// 一半想转到邮件继续,或者想把一封邮件投进正在谈的那条会话。补全早就把平台
// 会话列为候选,这一跳补上投递侧。
//
// 新建会让人在 TUI 里看不到这封邮件带来的对话,而那正是接管的目的。
//
// opencode 上这件事最省力会话由服务端持有单一写者promptAsync 本来
// 就是「给这个 session id 发一轮」,不区分谁建的。**不需要**校验它是否活着。
const adoptedID = adoptedSessionID(data);
if (adoptedID) {
// 平台侧那条会话可能已经被人删了(镜像是快照,可以过期)。
// 校验一次:直接 prompt 一个不存在的 id 会得到一个语焉不详的 HTTP 错误,
// 而这里能给出「它没了,去 .new」这种可操作的话。
let ok = false;
try {
const got = await client.session.get({ path: { id: adoptedID } });
ok = Boolean((got?.data ?? got)?.id);
} catch {
ok = false;
}
if (!ok) throw new Error(adoptMissingMessage(adoptedID, "可能已在界面上删除"));
if (mailSessionID) {
sessionMap.set(mailSessionID, adoptedID);
reverseMap.set(adoptedID, mailSessionID);
// 标记为邮件驱动:接管之后这条会话**开始**参与邮件往来,
// 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。
mailDrivenSessions.add(adoptedID);
}
console.error(`[mail-bridge] 接管平台会话 ${adoptedID}(邮件会话 ${mailSessionID}`);
return { sessionID: adoptedID, reused: true, adopted: true };
}
// 工作目录取**寻址里的 path 位**,而不是插件启动时那个固定的 directory。
//
// 三维地址 name@path.session 的 path 就是「希望它在哪儿干活」。用固定的