Files
MailUI4Agents/gateway/internal/repo/platform_sessions.go
JianFeeeee 255c799a40 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)
2026-09-04 11:14:44 +08:00

328 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// ---------- 平台会话镜像 ----------
//
// Agent 平台自己也在开会话:有些经由邮件驱动,有些是人直接在平台界面上开的。
// 写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续 —— 而 Gateway
// 只看得见邮件驱动的那部分。
//
// **由插件在心跳里上报Gateway 不反向拉取。**
// 当前架构是单向的Agent 持密钥主动连 GatewayGateway 从不外呼);
// 让 Gateway 去调平台接口需要它保存各平台的地址与凭证,那是另一套信任模型。
// 代价是插件没运行时同步不了 —— 但插件没运行时邮件本来也投不进去。
// PlatformSession 是插件上报的一条平台侧会话。
type PlatformSession struct {
PlatformID string `json:"platform_id"`
Workspace string `json:"workspace"`
Slug string `json:"slug,omitempty"`
Title string `json:"title,omitempty"`
MailDriven bool `json:"mail_driven"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// maxPlatformSessions 限制单次上报的会话数。
//
// 一个长期运行的平台可以累积上千条会话,而候选列表上千项对人没有意义。
// 插件按最近活跃排序后上报前 N 条即可。
const maxPlatformSessions = 200
// ReplacePlatformSessions 整表替换某 Agent 的平台会话镜像。
//
// 整表替换而非增量合并:镜像是平台当前状态的快照。增量合并会让已经删掉的
// 平台会话永远留在候选列表里,而那正是「选了却送不到」的来源
// —— session 位是三态语义,指向一条不存在的会话会直接 404。
func ReplacePlatformSessions(ctx context.Context, agentName string, list []PlatformSession) error {
agentName = strings.TrimSpace(agentName)
if agentName == "" {
return nil
}
if len(list) > maxPlatformSessions {
list = list[:maxPlatformSessions]
}
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM agent_platform_sessions WHERE agent_name = $1`, agentName); err != nil {
return err
}
seen := map[string]bool{}
for _, ps := range list {
id := strings.TrimSpace(ps.PlatformID)
if id == "" || seen[id] {
continue
}
seen[id] = true
driven := 0
if ps.MailDriven {
driven = 1
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO agent_platform_sessions
(agent_name, platform_id, workspace, slug, title, mail_driven, updated_at, reported_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`, agentName, id, strings.TrimSpace(ps.Workspace), strings.TrimSpace(ps.Slug),
strings.TrimSpace(ps.Title), driven, ps.UpdatedAt); err != nil {
return err
}
}
return tx.Commit()
}
// SessionCandidate 是「续谈某条会话」的一个候选项。
type SessionCandidate struct {
// Alias 是填进 session 位的值 —— 候选项的实际用途就是它
Alias string `json:"alias"`
// Title 给人看,用来分辨两条别名相似的会话在谈什么
Title string `json:"title,omitempty"`
// Source 说明这条候选从哪来:
// mail 本侧邮件线索(可直接送达)
// platform 平台侧会话镜像(本侧还没有对应线索)
Source string `json:"source"`
// Unread 仅 mail 来源有意义
Unread int `json:"unread,omitempty"`
}
// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。
//
// 两个来源合并:
// 1. 本侧邮件线索sessions.workspace 匹配,或历史数据里靠 mails 反推)
// 2. 平台会话镜像里带 slug 的那些
//
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) {
out := []SessionCandidate{}
seen := map[string]int{} // alias -> out 下标
// ---- 来源 1本侧邮件线索 ----
//
// sessions.workspace 是权威来源。它是新加的列,历史会话为空串,
// 因此保留 mails 反推作为兜底:`s.workspace = $2 OR (s.workspace = '' AND <mails 反推>)`。
// 反推只看 to_workspace —— Agent 回信时 from_workspace 存的是 Agent 名而非路径,
// 拿它比路径永远匹配不上。
rows, err := db.DB.QueryContext(ctx, `
SELECT s.session_alias,
COALESCE(s.subject, ''),
(SELECT COUNT(*) FROM mails u
WHERE u.session_id = s.session_id AND u.status = 'unread')
FROM sessions s
WHERE s.session_alias IS NOT NULL AND s.session_alias <> ''
AND s.status <> 'archived'
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
)
AND ($2 = ''
OR s.workspace = $2
OR (s.workspace = '' AND EXISTS (
SELECT 1 FROM mails w
WHERE w.session_id = s.session_id
AND COALESCE(w.to_workspace,'') = $2
)))
AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3)
OR EXISTS (
SELECT 1 FROM mails mm
WHERE mm.session_id = s.session_id
AND (mm.from_name = $3 OR mm.to_name = $3
OR `+db.CCHas("mm.cc_list", 3)+`)
))
ORDER BY s.updated_at DESC
`, peerName, path, forUser)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var alias, title string
var unread int
if err := rows.Scan(&alias, &title, &unread); err != nil {
return out, err
}
if alias == "" {
continue
}
seen[alias] = len(out)
out = append(out, SessionCandidate{
Alias: alias, Title: title, Source: "mail", Unread: unread,
})
}
if err := rows.Err(); err != nil {
return out, err
}
// ---- 来源 2平台会话镜像 ----
prows, err := db.DB.QueryContext(ctx, `
SELECT slug, title
FROM agent_platform_sessions
WHERE agent_name = $1
AND slug <> ''
AND ($2 = '' OR workspace = $2)
-- 不用 NULLS LAST它要 SQLite 3.30+,而驱动自带的版本不由我们控制。
-- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。
ORDER BY COALESCE(updated_at, reported_at) DESC
`, peerName, path)
if err != nil {
// 镜像查不到不该让整个补全失败:本侧线索已经够用了
return out, nil
}
defer prows.Close()
for prows.Next() {
var slug, title string
if err := prows.Scan(&slug, &title); err != nil {
break
}
if slug == "" {
continue
}
if i, ok := seen[slug]; ok {
// 本侧已有同名线索:保留 mail 来源(它保证送得到),
// 但补上镜像的标题 —— 平台侧标题通常比会话建立时的主题更贴切
if out[i].Title == "" && title != "" {
out[i].Title = title
}
continue
}
seen[slug] = len(out)
out = append(out, SessionCandidate{Alias: slug, Title: title, Source: "platform"})
}
return out, nil
}
// SetSessionWorkspace 记下会话所属的工作目录。
//
// 只在为空时写入:会话的工作区在建立时就定下了,之后不该被一封发往
// 别处的邮件改掉 —— 那会让这条会话在候选列表里凭空换一个工作区。
func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() string }, workspace string) error {
ws := strings.TrimSpace(workspace)
if ws == "" {
return nil
}
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET workspace = $1 WHERE session_id = $2 AND workspace = ''`,
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
}