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

@ -2,10 +2,13 @@ package repo
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// ---------- 平台会话镜像 ----------
@ -100,8 +103,8 @@ type SessionCandidate struct {
// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。
//
// 两个来源合并:
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
// 2. 平台会话镜像里带 slug 的那些
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
// 2. 平台会话镜像里带 slug 的那些
//
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
@ -220,3 +223,105 @@ func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() stri
ws, sessionID.String())
return err
}
// ---------- 接管平台会话 ----------
//
// TUI 与邮箱是同一个 Agent 的**两个入口**,不是两套隔离的世界。
// 人在平台界面上开的会话,应该也能被邮件投进去 —— 补全早就把它们列为候选,
// 缺的只是投递侧这一跳。
//
// 「接管」= 在本侧建一条会话并把 platform_id 记上。之后:
// - 这条会话在 sessions 表里有正式身份(可寻址、有预算、能归档)
// - 插件收到投递事件时看到 platform_id,就去 resume 那条平台会话
// 而不是新建一条
//
// 一条平台会话只能被接管一次:第二次投递复用第一次建的本侧会话,
// 否则同一条 TUI 对话会在邮箱里裂成多条互不相干的线索。
// FindPlatformSession 按 (agent, slug, workspace) 找一条平台会话镜像。
//
// workspace 为空表示不限(地址省略 path 位时)。返回 platform_id 与它的
// 真实 workspace —— 后者是权威的:**会话的 cwd 在它创建时就定了**,
// 地址里的 path 位若与之不同,以会话为准。人是从候选列表里选的,
// 他要的是「那条会话」而不是「那个目录」。
func FindPlatformSession(ctx context.Context, agentName, slug, workspace string) (platformID, realWorkspace, title string, err error) {
agentName = strings.TrimSpace(agentName)
slug = strings.TrimSpace(slug)
if agentName == "" || slug == "" {
return "", "", "", ErrSessionNotFound
}
ws := strings.TrimSpace(workspace)
err = db.DB.QueryRowContext(ctx, `
SELECT platform_id, workspace, title
FROM agent_platform_sessions
WHERE agent_name = $1 AND slug = $2
AND ($3 = '' OR workspace = $3)
ORDER BY COALESCE(updated_at, reported_at) DESC
LIMIT 1
`, agentName, slug, ws).Scan(&platformID, &realWorkspace, &title)
if errors.Is(err, sql.ErrNoRows) {
return "", "", "", ErrSessionNotFound
}
return platformID, realWorkspace, title, err
}
// FindSessionByPlatformID 找出已经接管了某条平台会话的本侧会话。
//
// 返回 ErrSessionNotFound 表示还没被接管。归档的也算 —— 让归档过的会话
// 重新被接管会造出第二条本侧会话,同一条 TUI 对话在邮箱里就裂成两截。
// 需要恢复的话人应该去取消归档。
func FindSessionByPlatformID(ctx context.Context, agentName, platformID string) (uuid.UUID, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT s.session_id
FROM sessions s
WHERE s.platform_id = $1
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
)
ORDER BY s.updated_at DESC
LIMIT 1
`, platformID, agentName).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrSessionNotFound
}
return id, err
}
// AdoptPlatformSession 接管一条平台会话:建本侧会话并绑定 platform_id。
//
// alias 用平台自己的 slug —— 「别名复用平台命名」是既定决策,而且人在补全里
// 看到的就是那个 slug,投递后别名换成别的会让他找不到自己刚发的信。
//
// workspace 用平台会话的真实 cwd 而不是地址里的 path 位,理由见
// FindPlatformSession 的注释。
func AdoptPlatformSession(ctx context.Context, agentName, platformID, slug, workspace, subject string) (uuid.UUID, error) {
// slug 可能与本侧某条无关会话撞名(别名全局唯一)。撞了就加后缀 ——
// EnsureSessionAlias 已有这套逻辑,这里先建后命名即可。
id, err := CreateSession(ctx, nil, agentName, subject, workspace)
if err != nil {
return uuid.Nil, err
}
if _, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET platform_id = $1 WHERE session_id = $2`,
platformID, id); err != nil {
return uuid.Nil, err
}
// 别名尽量用 slug;撞名时 EnsureSessionAlias 自动加后缀
_, _ = EnsureSessionAlias(ctx, id, slug)
return id, nil
}
// PlatformIDOf 读一条本侧会话绑定的平台会话 id(空 = 不是接管来的)。
//
// 投递时要把它放进 SSE 事件:插件据此决定 resume 还是新建。
func PlatformIDOf(ctx context.Context, sessionID uuid.UUID) string {
var pid string
if err := db.DB.QueryRowContext(ctx,
`SELECT platform_id FROM sessions WHERE session_id = $1`, sessionID).Scan(&pid); err != nil {
return ""
}
return pid
}