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:
2026-10-02 15:59:56 +08:00
parent 560c462768
commit 1b810a4898
5 changed files with 592 additions and 11 deletions

View File

@ -280,6 +280,9 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.URL.Query().Get("name"))
path := strings.TrimSpace(r.URL.Query().Get("path"))
// flatten=1:一次给出该 name 的**全部**可投递地址(各 path 下的会话都带上
// 自己的 path 与完整地址)。见下方那一段的说明。
flatten := r.URL.Query().Get("flatten") == "1" || r.URL.Query().Get("flatten") == "true"
if name == "" {
agents, err := repo.ListAgents(r.Context(), "")
@ -304,11 +307,52 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
return
}
// flatten:把三段补全压成一次调用。
//
// ★ 2026-10-02(DSH 侧报告 A):原来的形状构成一个闭合的环 ——
// 给 name 只给 path,要再调一次才知道有哪些会话;而要枚举会话又必须先
// 知道 path,path 却只能猜(且 `root` 与 `/root` 会静默落到不同工作区)。
// 报告实测:投 `pi@root` 返回 200,落进一条标题为「拓展坞实测硬件正常…」
// 的无关会话 —— 投递成功,所以调用方不知道自己投错了。
//
// 可见性口径**不放宽**:与下面 path+name 那一支完全一致
//(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限。
if flatten {
if _, ok := agentScope(w, r, agentName); !ok {
return
}
cands, err := repo.SuggestAddressesForPeer(r.Context(), agentName, name)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list addresses")
return
}
addresses := make([]string, 0, len(cands))
for _, c := range cands {
addresses = append(addresses, c.Address)
}
JSON(w, http.StatusOK, map[string]any{
"kind": "session_flat",
"addresses": emptySlice(addresses),
"candidates": emptySlice(cands),
// paths 一并给回:调用方若要按 path 逐个展开,不必再调一次。
"paths": pathCandidates(mustPaths(r, name)),
})
return
}
if path == "" {
paths, _ := repo.SuggestPaths(r.Context(), name)
JSON(w, http.StatusOK, map[string]any{
"kind": "path",
"suggestions": emptySlice(paths),
// ★ 2026-10-02(DSH 侧报告 B/C):path 候选里混着**桥的内部会话目录**
// (如 /root/.pi/mail-sessions/<uuid>),实测 64 条候选里 33 条是它。
// 而 `root` 与 `/root` 在数据里真的是两个不同工作区(实测 1 条 vs 17 条),
// 外观只差一个斜杠。调用方无从区分,选中即静默投进错误线索。
//
// 所以这里**标注**而不是隐藏:隐藏会让人以为那些工作区不存在,
// 而它们确实是某些会话的真实 cwd(只是不该出现在「工作区」候选里)。
"paths": pathCandidates(paths),
})
return
}
@ -497,3 +541,71 @@ func participantsOf(m *models.Mail, alias string) []map[string]any {
}
return out
}
// pathCandidate 是一个 path 候选,外加**它是不是真工作区**的标注。
//
// ★ 2026-10-02(DSH 侧报告 B/C)。为什么必须标注而不是直接过滤:
//
// /root/.pi/mail-sessions/<uuid> 这类**桥内部目录**不是工作区,
// 但它确实是某些会话的真实 cwd
// (报告实测:投 pi@root 落进的那条会话
// workspace='root',uuid 正是其中一个)
// 滤掉它 ⇒ 那些会话在这个 name 下彻底不可见,调用方连"存在这样的线索"都不知道
// 留下不标 ⇒ 64 条候选里 33 条是噪声,模型选中即静默投错
//
// 所以两条信息都给:suggestions 保持原样(向后兼容,各桥按它取 path),
// paths[] 里带 kind 标注,让调用方能降权或跳过。
type pathCandidate struct {
Path string `json:"path"`
// Kind 是 "workspace" 或 "bridge-internal"。
Kind string `json:"kind"`
// Note 只在 kind != "workspace" 时给,一句话说清它是什么。
Note string `json:"note,omitempty"`
// IsAbsolute 标出**相对路径**。`root` 与 `/root` 在数据里是两个不同工作区,
// 而外观只差一个开头的斜杠 —— 三维地址的 path 位会被原样当作 cwd。
IsAbsolute bool `json:"is_absolute"`
}
// bridgeInternalMarkers 是各桥把「会话存储」放在哪的痕迹。
//
// 这些目录不是工作区,而是桥为每条会话建的落地点。它们出现在 path 候选里
// 是因为 mails.to_workspace 记的就是**投递时的 path 位**,而模型发信时如果
// 猜了这类目录,信真的会落在那里(于是那条会话的 cwd 就成了它)。
var bridgeInternalMarkers = []string{
"/.pi/mail-sessions/",
"/mail-sessions/",
"/.agentmail/sessions/",
"/.zcode/mail-sessions/",
"/.dsh/",
}
func classifyPath(p string) pathCandidate {
c := pathCandidate{Path: p, Kind: "workspace", IsAbsolute: strings.HasPrefix(p, "/")}
for _, m := range bridgeInternalMarkers {
if strings.Contains(p, m) {
c.Kind = "bridge-internal"
c.Note = "这是 Agent 桥的内部会话存储目录,不是项目工作区;投到这里的信会把会话 cwd 变成它"
break
}
}
if !c.IsAbsolute {
c.Note = strings.TrimSpace(c.Note + " 另:这是相对路径,与 /" + strings.TrimPrefix(p, "/") + " 是两个不同工作区")
}
return c
}
func pathCandidates(paths []string) []pathCandidate {
out := make([]pathCandidate, 0, len(paths))
for _, p := range paths {
out = append(out, classifyPath(p))
}
return out
}
func mustPaths(r *http.Request, name string) []string {
paths, err := repo.SuggestPaths(r.Context(), name)
if err != nil {
return nil
}
return paths
}