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

@ -209,8 +209,9 @@ new_mail 到达
├─ 1. 去重mail_id 已在 deliveredMails 里 → 丢弃
├─ 2. 解析 cwd = resolveWorkspaceCwd(to_workspace, 兜底)
├─ 3. 查映射session_id → 平台会话 id
│ 命中且会话还活着 → 续谈inject_turn
否则 → 新建会话(不传占位标题
│ 命中且会话还活着 → 续谈inject_turn
platform_session_id 非空 → **接管**那条平台会话B-3.7
│ 否则 → 新建会话(不传占位标题)
├─ 4. 按 modelAttemptOrder 逐个尝试,等异步结论(见 D-3
├─ 5. 注入提示词:告诉模型「调 read_inbox 读正文」「回信不用你自己发」
└─ 6. 登记 mail_id 到 deliveredMails
@ -224,6 +225,8 @@ new_mail 到达
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」 | MUST |
| B-3.5 | 提示词里带 `mail_id`,让模型能自己查这封 | SHOULD |
| B-3.6 | 投递失败要让人看到(日志 + 见 `B-6` | MUST |
| B-3.7 | `platform_session_id` 非空时**必须**投进那条平台会话,不得新建 | MUST |
| B-3.8 | 那条平台会话已不存在时**必须报错**,不得退回新建 | MUST |
> **B-3.1 是踩过最贵的坑之一**(详见第九节):平台按 cwd 给会话分组,用自己拼的
> 临时目录会让所有邮件会话既不属于任何项目、彼此也不同组 —— 界面上全落进
@ -233,6 +236,55 @@ new_mail 到达
> 而插件在轮次结束时也会自动转发一次 —— 同一件事两封邮件。生产里真实发生过。
> 说了之后仍要保留 `B-5.3` 的去重兜底:提示词是建议,去重是保证。
#### B-3.7 接管平台会话MUST
**平台界面TUI/GUI与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。**
人在界面上开的会话早就被 `C-8` 的会话快照上报成候选,写信时能在补全里选中;
若投递侧不认这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
Gateway 在本侧建一条会话并记下那条平台会话的 id「接管」随后**每次**投递
都在事件里带 `platform_session_id`。插件看到它就去那条平台会话里接着谈。
| 平台 | 接管方式 |
|---|---|
| opencode | `session.get` 确认存在 → 照常 `promptAsync({ path: { id } })` |
| DSH | `startAgent` 的 resume 分支(会话 id 换成平台自己那个) |
| pi | `SessionManager.open(file)` → 跑一轮 → **dispose** |
| HomeAgent | N/A无会话概念所有邮件注入同一事件循环 |
字段解析与失败话术走共用模块 `lib/adopt.js``adoptedSessionID` /
`adoptMissingMessage`)—— 字段名各写一遍时少个下划线就静默退化成「每封邮件
新开一条」,而那个错误不抛任何异常。
**接管之后必须把该会话加入 `mailDriven` 集合**`B-5.5` 的判据)。不加的话
邮件投进去了却永远没有回音:发件人只看到信发出去后再无音讯。
**B-3.8:平台侧那条会话已被删时报错,不要退回新建。** 镜像是快照、可以过期。
退回新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的 ——
`N-8`「404 后自动改用 `.new` 是禁止的」是同一条原则。错误话术必须给出
可执行的下一步(用 `.new`),只说「不存在」的话模型会原地重试同一个地址。
##### 文件型会话pi必须短暂持有
pi 的会话是磁盘上的 `.jsonl`**没有任何锁机制**SDK 里 `flock`/`lockfile`
命中为 0它假定「一个文件一个持有者」。写入是纯 append所以两个持有者不会
把文件截断;坏的是**各自的内存索引**:活着的 `SessionManager` 不 watch 文件,
对方追加的行自己看不见,于是算出的 `parentId` 指向一个对方不知道的 entry
会话树分叉。
取舍是 **open → 跑一轮 → dispose不放进长期缓存**。下一封邮件重新 open
那一次读到的就是界面那边写的全部内容。窗口是一轮对话的时长。
三处配套细节,少一个就有实测过的坏处:
- **`isStreaming` 时不释放**。同一条会话可能已经排了下一封邮件,此时 dispose
会把排着的那轮一起杀掉。排着的那轮结束时会再触发轮次结束事件,由它释放。
- **兜底计时器**(轮次超时的两倍)。轮次结束事件不来就永远握着文件;
计时器要 `unref()`,否则它会阻止进程退出。
- **接管会话跳过命名同步(`C-11`**。它的别名是人从补全里选的那个 slug
同步会双向改坏它 —— 别名撞名时 Gateway 加后缀,而定稿别名又回写进平台侧,
于是下一次快照上报的 slug 变成带后缀那个,人选的名字凭空消失(实测撞出来过)。
### B-4 收到 `permission_decision`MUST
| # | 要求 | 强度 |
@ -794,7 +846,8 @@ Agent 侧只会收到两个事件:
"to_workspace": "/home/program/agentmail",
"session_alias": "refactor-imports",
"reply_address": "admin@.refactor-imports",
"self_address": "pi@/home/program/agentmail.refactor-imports"
"self_address": "pi@/home/program/agentmail.refactor-imports",
"platform_session_id": ""
}
```
@ -806,6 +859,7 @@ Agent 侧只会收到两个事件:
| `session_alias` | 这条会话今后的寻址名 |
| `reply_address` | 「把回信发回这条会话」的现成地址 |
| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 |
| `platform_session_id` | 非空 = 投进**这条已存在的平台会话**(见 `B-3.7`);空 = 照旧 |
> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`
> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址
@ -1059,6 +1113,21 @@ GET /api/v1/attachments/{id}
[ ] 发到一个不存在的别名
→ 404且平台侧没有新建任何会话N-8
[ ] 接管平台会话B-3.7):在平台界面里手动开一条会话并聊几句
→ 等一次心跳sqlite3 "SELECT slug FROM agent_platform_sessions
WHERE agent_name='<name>' AND mail_driven=0" → 出现它的 slug
→ 发一封到 <name>@<那条会话的 workspace>.<那个 slug>
→ 日志显示「接管/resume」而**不是**「新会话」
→ 让模型回答「这条会话之前在谈什么」→ 答案含界面上聊过的内容(上下文装回来了)
→ sqlite3 "SELECT platform_id FROM sessions WHERE session_alias='<slug>'"
→ 等于那条平台会话的 id
→ 收到自动回信(接管后也进 mailDrivenB-5.5
→ 再发第二封:复用**同一条**本侧会话,不再新建(避免线索裂成多条)
→ 平台侧会话文件/记录里**没有**新增改名条目(接管会话跳过 C-11
[ ] 平台侧删掉那条会话后再投同一个别名
→ 报错且话术含 `.new`,平台侧**没有**新建任何会话B-3.8
[ ] 模型主动调 send_mail 回信的那一轮
→ 只有一封邮件没有额外的自动转发B-5.3