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:
@ -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
|
||||
→ 收到自动回信(接管后也进 mailDriven,B-5.5)
|
||||
→ 再发第二封:复用**同一条**本侧会话,不再新建(避免线索裂成多条)
|
||||
→ 平台侧会话文件/记录里**没有**新增改名条目(接管会话跳过 C-11)
|
||||
|
||||
[ ] 平台侧删掉那条会话后再投同一个别名
|
||||
→ 报错且话术含 `.new`,平台侧**没有**新建任何会话(B-3.8)
|
||||
|
||||
[ ] 模型主动调 send_mail 回信的那一轮
|
||||
→ 只有一封邮件,没有额外的自动转发(B-5.3)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user