fix(寻址)★★: 补「按 name 直出全部可投递地址」+ 标注 path 候选里的坑
## 起因 DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。 三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它 必须先知道 path —— 但 path 恰恰是调用方无从得知的: 给 name → 只给 path(要再调一次才知道有哪些会话) 给 name+path → 给会话别名(但 path 得先猜对) 于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题 为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。 ## 修法 **① A 项:`flatten=1` 一次给出全部可投递地址** `SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带 `path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼, 拼错就是那个「猜错比报错更糟」。 可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。 按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信 仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。 **② B/C 项:标注而非隐藏** `paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。 选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`) 确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是 其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条 是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。 `suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序 (刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。 ## ★★ 顺带修掉一个生产级缺陷(实测撞出来的) 给 `SessionCandidate` 加 `LastActivity` 时用了: COALESCE(s.updated_at, '0001-01-01 00:00:00+00') COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan` ⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。 生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。 而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」—— 本次是我自己加的列触发的,排查花了几步。 改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。 注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。 ## MCP 侧同步 `suggest_address` 加 `flatten` 参数,且**渲染必须单独写**: flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句 「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。 path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径 (`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。 ## 判据(8 格) 含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、 「两个工作区都要出现」(原形状缺的就是这一维)、 「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、 「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。 **变异验证**: COALESCE 兜底(那个真 bug) → 红 1 ✓ flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path") ## 实测校准了一处报告里的数字 报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** —— 服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是 「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建, 恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。 §E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。 全量 14 包绿。
This commit is contained in:
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -176,6 +177,12 @@ type SessionCandidate struct {
|
||||
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 下可续谈的会话。
|
||||
@ -214,7 +221,8 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
COALESCE(s.subject, ''),
|
||||
COALESCE(s.platform_id, ''),
|
||||
(SELECT COUNT(*) FROM mails u
|
||||
WHERE u.session_id = s.session_id AND u.status = 'unread')
|
||||
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'
|
||||
@ -247,7 +255,16 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
for rows.Next() {
|
||||
var alias, title, pid string
|
||||
var unread int
|
||||
if err := rows.Scan(&alias, &title, &pid, &unread); err != nil {
|
||||
// 直接扫成 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 == "" {
|
||||
@ -256,6 +273,7 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
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
|
||||
@ -277,7 +295,7 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
mirrorArgs = append(mirrorArgs, *onlyWorkspace)
|
||||
}
|
||||
prows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT slug, title, platform_id
|
||||
SELECT slug, title, platform_id, updated_at
|
||||
FROM agent_platform_sessions
|
||||
WHERE agent_name = $1
|
||||
AND slug <> ''
|
||||
@ -294,7 +312,10 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
|
||||
for prows.Next() {
|
||||
var slug, title, pid string
|
||||
if err := prows.Scan(&slug, &title, &pid); err != nil {
|
||||
// 同上:不用 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 == "" {
|
||||
@ -316,7 +337,15 @@ func suggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
|
||||
continue
|
||||
}
|
||||
seen[slug] = len(out)
|
||||
out = append(out, SessionCandidate{Alias: slug, Title: title, Source: "platform"})
|
||||
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
|
||||
}
|
||||
@ -484,3 +513,85 @@ func PlatformSessionFor(ctx context.Context, sessionID uuid.UUID) (platformID, o
|
||||
}
|
||||
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"`
|
||||
// PathSource 说明这个 path 是从哪学来的(投递历史 / 平台镜像 / 注册自报),
|
||||
// 供调用方判断可信度与新鲜度。
|
||||
PathSource string `json:"path_source,omitempty"`
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].LastActivity.After(out[j].LastActivity)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user