## 缺口(部署后实测才发现,是我自己引入的)
第一版 flatten 只在响应的 `paths[]` 数组里标注。实测:
222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/<uuid>)
而标注在**另一个数组** —— 模型必须自己把 candidates 与 paths 对照才认得出
那正是「§C 噪声淹没信号」换个位置复活。我在动手前的判断是「先修 C 再修 A,
否则新端点会把噪声一起放大」—— 做了 A,却让 C 的噪声原样跟进了 A。
只在真机跑过 `flatten=1` 才看见:单测全绿(它们只断言了 paths[] 有标注),
是生产数据的 16% 把它翻出来的。
## 修法
`AddressedCandidate` 逐候选带 `path_kind` / `path_note` / `is_absolute_path`,
MCP 渲染逐条打 `⚠`。
marker 收敛到 repo 层一份,handler 的 `classifyPath` 改为委托调用:
同一目录在 path 列表里标成「工作区」、在候选列表里却没标 ——
而那两个数组是**同一次调用**返回的。两处各写一份 marker 时,
改一处忘另一处就会出现这种自相矛盾,且没有任何报错。
## 判据(2 格)
TestFlattenAnnotatesEachCandidate 桥内部目录/相对路径能分类 + 带说明;
真工作区不得被误标(否则全是噪声)
TestClassifyPathAgreesWithRepo handler 与 repo 口径必须逐条一致
## 顺带
第一版 flatten 本身已验证有效(生产实测):
flatten=1 → 222 条候选、66 个工作区
/home/program/agentmail 125 条 · /root 16 条 · root 2 条
⇒ root 与 /root **同时可见**且各自带 path,不再需要「先猜 path 再枚举」
path 标注:66 条候选里 35 条桥内部目录 + 1 条相对路径被标出
651 lines
26 KiB
Go
651 lines
26 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"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 在**本次上报所覆盖的那些工作区**里的镜像。
|
||
|
||
# 「整表替换」的域是 (agent, workspace),不是 agent(2026-09-26 修)
|
||
|
||
原先的 DELETE 域是 `agent_name` 单列 —— 而**每个上报者只知道自己一个 directory**:
|
||
|
||
plugins/opencode-mail-bridge/index.js:1147
|
||
client.session.list({ query: directory ? {directory} : undefined })
|
||
|
||
于是 A 工作区的桥上报一次,就把 B 工作区上报过的镜像全擦掉;下个工作区的桥
|
||
再上报,又擦掉 A 的。表现为「镜像按 project 轮换」。
|
||
|
||
# 生产实测(不是推断)
|
||
|
||
sqlite3 agent_platform_sessions 按 workspace 分组:
|
||
dsh 77 条散在 **25** 个工作区(/home/program/agentmail 25、/tmp 20 …)
|
||
pi 151 条散在 **62** 个工作区
|
||
|
||
# 后果已在生产数据上可见
|
||
|
||
镜像被擦 ⇒ `notify/mail.go` 的 `PlatformSessionFor` 查不到 ⇒
|
||
`sessions.platform_id` 留空 ⇒ 收方拿不到平台会话 id。
|
||
实测 **18 条活跃会话里 17 条 `platform_id` 为空**。
|
||
|
||
而空 platform_id 的去向不止"少一个跳转":`notify/mail.go:94` 用它决定
|
||
`platform_session_id` 发给谁,owner 取错就抛「平台侧会话已删」⇒ **邮件静默消失**。
|
||
|
||
# 为什么仍然是"整表替换"而不是增量合并
|
||
|
||
镜像是平台当前状态的快照。增量合并会让已删掉的平台会话永远留在候选列表里,
|
||
而 session 位是三态语义,指向不存在的会话会直接 404("选了却送不到")。
|
||
⇒ 保持整表替换,只把**域收窄到本次上报覆盖的工作区**。
|
||
|
||
# 上报跨多个工作区时的语义
|
||
|
||
一次上报里出现多个 workspace ⇒ 那些工作区**各自**整表替换;
|
||
**本次没出现的**工作区一律不动。
|
||
|
||
# 域里含 workspace 的必要性
|
||
|
||
`agent_platform_sessions` 的主键是 `(agent_name, platform_id)`。
|
||
同一个 platform_id 出现在两个 workspace 会撞 `UNIQUE constraint failed`,
|
||
而这里没有 `ON CONFLICT` + `defer tx.Rollback()` ⇒ **整个 DELETE 回滚**、
|
||
`agents.go` 降级为 -1 ⇒ 表现为「心跳一直成功而镜像永久停滞」。
|
||
⇒ 该主键是否也要加 workspace,见 DEBTS.json 的 platform-mirror-replace-domain-too-wide
|
||
|
||
(**那一项未决**:本函数只消除了"擦错别人",没有消除"同 id 跨 ws 撞约束")。
|
||
*/
|
||
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]
|
||
}
|
||
|
||
// 本次上报覆盖了哪些工作区 —— DELETE 域就按它圈定。
|
||
//
|
||
// 用 map 去重且**保序**:同一工作区在 list 里出现多次只算一次(下面的
|
||
// seen 也会按 id 去重,但 DELETE 域要先算出来)。
|
||
wsSeen := map[string]bool{}
|
||
var wsOrder []string
|
||
for _, ps := range list {
|
||
ws := strings.TrimSpace(ps.Workspace)
|
||
if ws != "" && !wsSeen[ws] {
|
||
wsSeen[ws] = true
|
||
wsOrder = append(wsOrder, ws)
|
||
}
|
||
}
|
||
|
||
tx, err := db.DB.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 只清本次上报覆盖的工作区。wsOrder 为空(上报里一项 workspace 都没有)
|
||
// ⇒ **什么都不删**:那说明这次上报不带任何工作区信息,无从判断该清谁,
|
||
// 按 agent 清等于回到那个缺陷。
|
||
//
|
||
// `len(wsOrder) > 0` 这个守卫不是可有可无的:空列表会拼出 `IN ()`,
|
||
// 两种方言都恰好恒假(SQLite 与 PostgreSQL 都是),所以**行为上**与
|
||
// "什么都不删"相同 —— 但那是依赖两个数据库的一条隐式巧合,而不是
|
||
// 写代码的人读得出来的保证。显式守卫把意图摆在语句上。
|
||
if len(wsOrder) > 0 {
|
||
ph := make([]string, len(wsOrder))
|
||
args := make([]any, 0, len(wsOrder)+1)
|
||
args = append(args, agentName)
|
||
for i, w := range wsOrder {
|
||
ph[i] = fmt.Sprintf("$%d", i+2)
|
||
args = append(args, w)
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`DELETE FROM agent_platform_sessions WHERE agent_name = $1 AND workspace IN (`+
|
||
strings.Join(ph, ",")+`)`, args...); 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"`
|
||
// LastActivity 是这条会话最近一次有邮件的时间(UTC)。
|
||
//
|
||
// 为什么加它:2026-10-02 的 A 项要「一次列全部可投递地址」,而候选可能
|
||
// 上百条 —— 没有时间序,模型无从判断该续哪一条,只能按返回顺序取第一条。
|
||
// 它也是排序键(见 SuggestAddressesForPeer)。
|
||
LastActivity time.Time `json:"last_activity,omitempty"`
|
||
}
|
||
|
||
// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。
|
||
//
|
||
// 两个来源合并:
|
||
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
|
||
// 2. 平台会话镜像里带 slug 的那些
|
||
//
|
||
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
|
||
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
|
||
func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) {
|
||
return suggestSessionCandidates(ctx, forUser, peerName, path, nil)
|
||
}
|
||
|
||
func suggestSessionCandidates(ctx context.Context, forUser, peerName, path string, onlyWorkspace *string) ([]SessionCandidate, error) {
|
||
out := []SessionCandidate{}
|
||
seen := map[string]int{} // alias -> out 下标
|
||
|
||
// 工作区收窄只在本侧线索那条查询里能加:`sessions.workspace` 才是会话的工作区,
|
||
// 镜像表(来源 2)自己那份 workspace 列另算。
|
||
wsClause := ""
|
||
wsArgs := []any{}
|
||
if onlyWorkspace != nil {
|
||
wsClause = ` AND COALESCE(s.workspace, '') = $4`
|
||
wsArgs = append(wsArgs, *onlyWorkspace)
|
||
}
|
||
|
||
// ---- 来源 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, ''),
|
||
COALESCE(s.platform_id, ''),
|
||
(SELECT COUNT(*) FROM mails u
|
||
WHERE u.session_id = s.session_id AND u.status = 'unread'),
|
||
s.updated_at
|
||
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)+`)
|
||
))`+wsClause+`
|
||
ORDER BY s.updated_at DESC
|
||
`, append([]any{peerName, path, forUser}, wsArgs...)...)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var alias, title, pid string
|
||
var unread int
|
||
// 直接扫成 time.Time:repo.go:1702 的 ListContactsFor 对同一列就是这么读的。
|
||
//
|
||
// ★ 这里**不能**用 SQL 的 COALESCE 兜底(实测踩到):
|
||
// `COALESCE(s.updated_at, '0001-01-01 00:00:00+00')` 让驱动返回 **string**,
|
||
// 扫进 time.Time 报 "unsupported Scan" ⇒ 本行 `return out, err` ⇒
|
||
// **整个候选列表变空**(实测一条都列不出)。
|
||
// 那个错误信息里完全没有线索指向「是你加的 COALESCE 害的」。
|
||
// 改成扫进 sql.NullTime:NULL 就是零值,其余交给驱动(同 ListContactsFor)。
|
||
var updated sql.NullTime
|
||
if err := rows.Scan(&alias, &title, &pid, &unread, &updated); err != nil {
|
||
return out, err
|
||
}
|
||
if alias == "" {
|
||
continue
|
||
}
|
||
seen[alias] = len(out)
|
||
out = append(out, SessionCandidate{
|
||
Alias: alias, Title: title, Source: "mail", Unread: unread,
|
||
LastActivity: updated.Time,
|
||
})
|
||
if pid != "" {
|
||
seen["pid:"+pid] = len(out) - 1
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return out, err
|
||
}
|
||
|
||
// ---- 来源 2:平台会话镜像 ----
|
||
//
|
||
// 镜像表有自己的 workspace 列,所以这里另写一个参数位($3),
|
||
// 不与来源 1 复用 $4 —— 两条查询的参数个数不同,硬凑一个编号只会让
|
||
// 下一次改动踩到"占了位子却没人绑"的坑。
|
||
mirrorWS := ""
|
||
mirrorArgs := []any{peerName, path}
|
||
if onlyWorkspace != nil {
|
||
mirrorWS = ` AND COALESCE(workspace, '') = $3`
|
||
mirrorArgs = append(mirrorArgs, *onlyWorkspace)
|
||
}
|
||
prows, err := db.DB.QueryContext(ctx, `
|
||
SELECT slug, title, platform_id, updated_at
|
||
FROM agent_platform_sessions
|
||
WHERE agent_name = $1
|
||
AND slug <> ''
|
||
AND ($2 = '' OR workspace = $2)`+mirrorWS+`
|
||
-- 不用 NULLS LAST:它要 SQLite 3.30+,而驱动自带的版本不由我们控制。
|
||
-- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。
|
||
ORDER BY COALESCE(updated_at, reported_at) DESC
|
||
`, mirrorArgs...)
|
||
if err != nil {
|
||
// 镜像查不到不该让整个补全失败:本侧线索已经够用了
|
||
return out, nil
|
||
}
|
||
defer prows.Close()
|
||
|
||
for prows.Next() {
|
||
var slug, title, pid string
|
||
// 同上:不用 COALESCE 兜底(会让驱动返回 string 而扫不进 time.Time)。
|
||
// ORDER BY 仍用 COALESCE(updated_at, reported_at) —— 那是排序,不进结果集。
|
||
var seenAt sql.NullTime
|
||
if err := prows.Scan(&slug, &title, &pid, &seenAt); err != nil {
|
||
break
|
||
}
|
||
if slug == "" {
|
||
continue
|
||
}
|
||
// 已被接管的平台会话不再单独列:选它也会落进已有的那条本侧线索,
|
||
// 但候选列表出现两次会让人以为有两条不同的会话(项目定位 x2 的场景)。
|
||
if pid != "" {
|
||
if _, dup := seen["pid:"+pid]; dup {
|
||
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",
|
||
LastActivity: func() time.Time {
|
||
if seenAt.Valid {
|
||
return seenAt.Time
|
||
}
|
||
return time.Time{}
|
||
}(),
|
||
})
|
||
}
|
||
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 s.status <> 'archived'
|
||
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
|
||
}
|
||
// 显式写入档位与强制力:接管一条平台会话没有父会话,
|
||
// 只靠 DB 默认值会在「schema 列定义变动」或「迁移补列给了不同默认」时
|
||
// 静默偏离预期 —— 显式写 'workspace' 是唯一可靠表述「这条会话是新接管的,
|
||
// 没有继承来源」的方式。与 me.go 新建会话那条路径一致。
|
||
if _, err := SetSessionPermissionMode(ctx, id, models.DefaultPermissionMode); err != nil {
|
||
return uuid.Nil, err
|
||
}
|
||
_ = SetSessionEnforcement(ctx, id, AgentModeEnforcement(ctx, agentName))
|
||
// 别名尽量用 slug;撞名时 EnsureSessionAlias 自动加后缀
|
||
_, _ = EnsureSessionAlias(ctx, id, slug)
|
||
return id, nil
|
||
}
|
||
|
||
// PlatformIDOf 读一条本侧会话绑定的平台会话 id(空 = 不是接管来的)。
|
||
//
|
||
// 投递时要把它放进 SSE 事件:插件据此决定 resume 还是新建。
|
||
//
|
||
// 只在「已知这条会话只有一个参与方」时用它。有抄送时必须用
|
||
// PlatformSessionFor 拿到归属方 —— 理由见那个函数。
|
||
func PlatformIDOf(ctx context.Context, sessionID uuid.UUID) string {
|
||
pid, _ := PlatformSessionFor(ctx, sessionID)
|
||
return pid
|
||
}
|
||
|
||
// PlatformSessionFor 返回一条本侧会话绑定的平台会话 id **及其归属 Agent**。
|
||
//
|
||
// # 为什么归属方是必须的
|
||
//
|
||
// `platform_id` 是**会话级**的一个值,而一封邮件可以有多个参与方。
|
||
// 把它无差别推给所有人,收到的一方会拿它去自己的磁盘上找会话文件 ——
|
||
// 那个 id 属于别的平台。
|
||
//
|
||
// 生产实测:会话 `16845133` 接管了 pi 的会话 `01a05a5e-…`,而那封邮件抄送了
|
||
// `dsh@/home/program/agentmail.new`。DSH 收到同一个 platform_session_id,
|
||
// 在 `~/.dsh/sessions/` 里查不到(那是 `/root/.pi/agent/sessions/` 下的文件),
|
||
// 于是走进「平台侧会话已删」那条防线抛错。那道防线本身是对的(N-8:
|
||
// 不能退回新建,否则人在界面上看不到这封邮件带来的对话),它拦下的却是
|
||
// 「别人的会话」—— 邮件因此静默消失,而插件侧的日志走的是不进 journalctl
|
||
// 的通道,连线索都没有。
|
||
//
|
||
// 归属方以镜像(`agent_platform_sessions.agent_name`,Agent 自己上报的)为准;
|
||
// 镜像整表替换,平台侧删了会话那行就没了,此时退回 `sessions.from_agent` ——
|
||
// `AdoptPlatformSession` 建会话时把归属 Agent 写在那里,是可靠的第二来源。
|
||
func PlatformSessionFor(ctx context.Context, sessionID uuid.UUID) (platformID, owner string) {
|
||
var pid, fromAgent string
|
||
var mirrored *string
|
||
if err := db.DB.QueryRowContext(ctx, `
|
||
SELECT COALESCE(s.platform_id, ''), COALESCE(s.from_agent, ''), aps.agent_name
|
||
FROM sessions s
|
||
LEFT JOIN agent_platform_sessions aps
|
||
ON aps.platform_id = s.platform_id AND COALESCE(s.platform_id, '') <> ''
|
||
WHERE s.session_id = $1`, sessionID).Scan(&pid, &fromAgent, &mirrored); err != nil {
|
||
return "", ""
|
||
}
|
||
if pid == "" {
|
||
return "", ""
|
||
}
|
||
if mirrored != nil && *mirrored != "" {
|
||
return pid, *mirrored
|
||
}
|
||
return pid, fromAgent
|
||
}
|
||
|
||
// AddressedCandidate 是一条**带完整三维地址**的会话候选。
|
||
//
|
||
// 与 SessionCandidate 的差别:多一个 Path,且 Alias/Path 组合出来就是可直接
|
||
// 塞进 send_mail 的 to —— 调用方不必自己拼(拼错就是本文件头记录的
|
||
// 「猜错比报错更糟」)。
|
||
type AddressedCandidate struct {
|
||
SessionCandidate
|
||
// Path 是这条会话**自己**的工作区。必须带出来:同一个 name 在不同 path 下
|
||
// 是不同的会话集合,而 `root` 与 `/root` 在数据里真的是两个不同工作区。
|
||
Path string `json:"path"`
|
||
// Address 是可直接投递的三维地址(name@path.alias)。
|
||
Address string `json:"address"`
|
||
// PathKind 与 PathNote 复用 handler 层的 pathCandidate 分类。
|
||
//
|
||
// ★ 为什么必须**逐候选**带,而不只是靠响应里那个 paths[] 数组:
|
||
// 实测 flatten 一次给 222 条候选,其中 37 条(16%)落在桥内部目录
|
||
// (/root/.pi/mail-sessions/<uuid>)。标注只放在 paths[] 里的话,
|
||
// 模型必须自己把 candidates 与 paths 两个数组对照才认得出 ——
|
||
// 而这正是 §C「噪声淹没信号」的翻版,只是换了位置。
|
||
// 逐条自带标注,扫一眼列表就知道该跳过哪些。
|
||
PathKind string `json:"path_kind,omitempty"`
|
||
PathNote string `json:"path_note,omitempty"`
|
||
// IsAbsolutePath 标出相对路径(`root` 与 `/root` 是两个不同工作区)。
|
||
IsAbsolutePath bool `json:"is_absolute_path"`
|
||
}
|
||
|
||
// SuggestAddressesForPeer 一次列出「我能投递的、属于 peerName 的全部地址」。
|
||
//
|
||
// # 为什么需要它(2026-10-02,DSH 侧报告的 A 项)
|
||
//
|
||
// 三段式寻址 `name@path.session` 里,session 段是**人的寻址入口**,而枚举它
|
||
// 必须先知道 path —— 但 path 恰恰是调用方无从得知的:SuggestPaths 给出的
|
||
// 是「历史上被投递过的全部路径」,包含 `root` 与 `/root` 这种只差一个斜杠、
|
||
// 却是两个不同工作区的值(实测 `/root` 下 17 条会话、`root` 下 1 条)。
|
||
//
|
||
// 原形状因此构成一个闭合的环:
|
||
//
|
||
// 给 name → 只给 path(要再调一次才知道有哪些会话)
|
||
// 给 name+path → 给会话别名(但 path 得先猜对)
|
||
//
|
||
// 报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题为
|
||
// 「拓展坞实测硬件正常…」的无关会话 —— 它的 workspace 恰好是 `root`,
|
||
// 而那正是 pi 桥内部会话目录 `/root/.pi/mail-sessions/<uuid>` 里的一个 uuid。
|
||
// 投递成功 ⇒ 调用方不知道自己投错了。
|
||
//
|
||
// # 可见性口径:与 SuggestSessionCandidates 完全一致
|
||
//
|
||
// 「我参与过 + 与该 name 匹配」。**不放宽** —— 报告本身也确认问题不在权限
|
||
// (同一批数据给了 path 就能列出 17 条,说明数据是可得的)。
|
||
//
|
||
// # 为什么按 path 分组返回而不是嵌套
|
||
//
|
||
// 嵌套(name → path → sessions)更整齐,但调用方要发一封「不知道在哪个
|
||
// path」的信时仍要自己遍历全部组。平铺一次给全,每个候选自带 address ——
|
||
// 一次调用就能拿到全部可投递地址,模型不必做「先猜 path 再枚举」的两步。
|
||
//
|
||
// 排序:先按最近活动倒序,再按 path、同序别名为稳定次序 ——
|
||
// 让「刚聊过的那条」排在前面(与 SuggestPaths 同取向)。
|
||
func SuggestAddressesForPeer(ctx context.Context, forUser, peerName string) ([]AddressedCandidate, error) {
|
||
paths, err := SuggestPaths(ctx, peerName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := []AddressedCandidate{}
|
||
seen := map[string]bool{} // path|alias 去重
|
||
for _, p := range paths {
|
||
cands, err := SuggestSessionCandidates(ctx, forUser, peerName, p)
|
||
if err != nil {
|
||
// 单个 path 失败不该让整次调用失败 —— 少几个候选,
|
||
// 比一个都拿不到有用得多(调用方仍可对每个 path 重试)。
|
||
continue
|
||
}
|
||
for _, c := range cands {
|
||
key := p + "|" + c.Alias
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
out = append(out, AddressedCandidate{
|
||
SessionCandidate: c,
|
||
Path: p,
|
||
Address: models.FormatAddress(peerName, p, c.Alias),
|
||
PathKind: classifyPathKind(p),
|
||
PathNote: classifyPathNote(p),
|
||
IsAbsolutePath: strings.HasPrefix(p, "/"),
|
||
})
|
||
}
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
return out[i].LastActivity.After(out[j].LastActivity)
|
||
})
|
||
return out, nil
|
||
}
|
||
|
||
// classifyPathKind / classifyPathNote 把「这个 path 是不是项目工作区」讲清楚。
|
||
//
|
||
// ★ 为什么放在 repo 层而不是只在 handler 层:标注要**逐候选**随行(见
|
||
// AddressedCandidate 的注释),所以 repo 拼候选时就得能分类。
|
||
// 判定口径与 handler 的 classifyPath 逐字一致(同一批 marker)。
|
||
var bridgeInternalMarkers = []string{
|
||
"/.pi/mail-sessions/",
|
||
"/mail-sessions/",
|
||
"/.agentmail/sessions/",
|
||
"/.zcode/mail-sessions/",
|
||
"/.dsh/",
|
||
}
|
||
|
||
// ClassifyPathKind 导出给 handler 层复用(marker 只有这一份)。
|
||
func ClassifyPathKind(p string) string { return classifyPathKind(p) }
|
||
|
||
// ClassifyPathNote 同上。
|
||
func ClassifyPathNote(p string) string { return classifyPathNote(p) }
|
||
|
||
func classifyPathKind(p string) string {
|
||
for _, m := range bridgeInternalMarkers {
|
||
if strings.Contains(p, m) {
|
||
return "bridge-internal"
|
||
}
|
||
}
|
||
return "workspace"
|
||
}
|
||
|
||
func classifyPathNote(p string) string {
|
||
var notes []string
|
||
if classifyPathKind(p) == "bridge-internal" {
|
||
notes = append(notes,
|
||
"这是 Agent 桥的内部会话存储目录,不是项目工作区;投到这里的信会把会话 cwd 变成它")
|
||
}
|
||
if !strings.HasPrefix(p, "/") {
|
||
notes = append(notes,
|
||
"这是相对路径,与 /"+strings.TrimPrefix(p, "/")+" 是两个不同工作区")
|
||
}
|
||
return strings.Join(notes, " ")
|
||
}
|