人在平台界面(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)
328 lines
12 KiB
Go
328 lines
12 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// ---------- 平台会话镜像 ----------
|
||
//
|
||
// Agent 平台自己也在开会话:有些经由邮件驱动,有些是人直接在平台界面上开的。
|
||
// 写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续 —— 而 Gateway
|
||
// 只看得见邮件驱动的那部分。
|
||
//
|
||
// **由插件在心跳里上报,Gateway 不反向拉取。**
|
||
// 当前架构是单向的(Agent 持密钥主动连 Gateway,Gateway 从不外呼);
|
||
// 让 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
|
||
}
|