fix(handler): 回信的 to_workspace 从会话 workspace 继承 —— 修「每封邮件多一条会话」

# 现象(生产实测)

在 DSH 界面上观察到的:每处理一封邮件就多出一条独立会话。

# 根因

`to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,而它取的是
**地址里的 path 位**。Agent 之间的回信、以及人在对话页点回复,地址里通常没有
path 位 —— 平台下发的 `reply_address` 就是这个形状(`FormatAddress(replyTo, "", alias)`)。

空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在一个
不同的空目录**里;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理一封邮件
就多出一条未分组会话」,而模型在那个空目录里什么项目文件也看不到。

实测取证:
  - 线上 5 个兜底目录 `~/.dsh/mail-sessions/mail-*` **全部是空的**(0 条目)
  - 全天 journalctl 里**没有任何**相关告警(代码用的是 `ctx.logger.warn`,
    而同一文件别处明确写着 DSH 的 logger 不进 journalctl)→ 完全静默
  - 走兜底的那条会话(8e982e96)里,`dsh → opencode` 那封 `to_workspace` 有值,
    而 `opencode → dsh` 的回信 **to_workspace 全为空** —— 而该会话自身的
    `sessions.workspace` 一直是有值的

# 修法

会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):回信本来就是
回给**那条会话**的,而那条会话知道自己属于哪个项目。规则抽成纯函数
`resolveToWorkspace(addrPath, sessionWorkspace, toIsHuman)`:

  1. 地址里写了 path → 照用(人的明确意图优先)
  2. 没写且收件方是**人** → 保持空(人没有工作目录;填了前端会拼出
     `gui-lab@/path.别名` 这种错地址,ToHuman 字段就是为此加的)
  3. 没写且收件方是 Agent → 用会话的 workspace

**改的是 `to`,不是只改建库那一行**:同一个值还进投递载荷(`to_workspace` /
`self_address`)。改一处另一处不改,会出现「API 读到的与插件推到的不是同一个
目录」——那正是本项目一直在治的静默不一致。

`notify/mail.go` 只加了一段注释说明 reply_address 的 path 位为何**刻意留空**
(它的语义是「**发件人**该在哪儿干活」),免得后人以为那是漏填。

# 测试

- `handler/toworkspace_test.go`:6 条规则用例 + 1 条**反向对照**
  (固定其他输入只翻转 toIsHuman,要求结果必须不同 —— 防止该参数被忽略后
  「给人也填 path」静默回归)
- `repo/session_workspace_test.go`:锁住**列名与真实 schema**。这个查询读不到时
  按设计返回空串,与「这条会话没有工作目录」无法区分 → 列名写错的功能表现是
  「看起来还在跑,只是工作目录永远继承不到」
This commit is contained in:
2026-09-12 11:19:19 +08:00
parent c19eea5e3c
commit a696b2a141
6 changed files with 241 additions and 0 deletions

View File

@ -0,0 +1,93 @@
package handler
import "testing"
// resolveToWorkspace 的判据。
//
// 每一条都对着一个真实后果,不是凑覆盖率:
// - Agent 间回信丢 path → 收信方落进一次性空目录(界面上每封多一条会话)
// - 给人类填 path → 界面拼出 `gui-lab@/root/proj.别名` 这种错地址
// - 地址里明确写了 path → 必须照用(人的意图优先于兜底)
func TestResolveToWorkspace(t *testing.T) {
const ws = "/root/projects/demo"
cases := []struct {
name string
addrPath string
sessionWS string
toIsHuman bool
want string
why string
}{
{
name: "地址里没写 path收件方是 Agent → 继承会话 workspace",
addrPath: "", sessionWS: ws, toIsHuman: false,
want: ws,
why: "这是 Agent 间回信与人点回复的常态;不继承就会落在一次性空目录里",
},
{
name: "地址里没写 path收件方是人 → 保持空",
addrPath: "", sessionWS: ws, toIsHuman: true,
want: "",
why: "给人类填 path 会拼出 gui-lab@/root/projects/demo.别名 这种错地址",
},
{
name: "地址里写了 path → 照用,不用会话 workspace",
addrPath: "/other/place", sessionWS: ws, toIsHuman: false,
want: "/other/place",
why: "地址里的 path 是发件人的明确意图,兜底不该覆盖它",
},
{
name: "地址里写了 path即使收件方是人 → 照用",
addrPath: "/other/place", sessionWS: ws, toIsHuman: true,
want: "/other/place",
why: "人自己写了 path 就按他写的来(与上一条同一个原则)",
},
{
name: "会话确实没有 workspace收件方是 Agent → 仍是空",
addrPath: "", sessionWS: "", toIsHuman: false,
want: "",
why: "空串的语义是「不知道」,不能凭空编一个目录出来",
},
{
name: "会话没有 workspace收件方是人 → 空",
addrPath: "", sessionWS: "", toIsHuman: true,
want: "",
why: "两边都空,行为与改动前完全一致(无回归)",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := resolveToWorkspace(c.addrPath, c.sessionWS, c.toIsHuman)
if got != c.want {
t.Errorf("resolveToWorkspace(%q, %q, human=%v) = %q期望 %q —— %s",
c.addrPath, c.sessionWS, c.toIsHuman, got, c.want, c.why)
}
})
}
}
// 反向对照:确认「收件方是否人类」这个维度**真的在起作用**。
//
// 如果哪天有人把 toIsHuman 用错(比如传反了、或当成常量传),上面的表里
// 仍会有一半用例通过 —— 它们只在 want 恰好相同的情况下碰到。这里固定其他
// 输入、只翻转这一个维度,要求结果必须不同:
//
// 不翻转就通过,说明这个参数被忽略了(恒为默认、或被短路掉),
// 那时「给人也填 path」的错地址会静默回归。
func TestResolveToWorkspace_HumanDimensionActuallyMatters(t *testing.T) {
const ws = "/root/projects/demo"
agent := resolveToWorkspace("", ws, false)
human := resolveToWorkspace("", ws, true)
if agent == human {
t.Fatalf("收件方是人类与是 Agent 得到了同一个结果 %q —— "+
"要么 toIsHuman 没起作用,要么规则退化成了一律填/一律不填", agent)
}
if human != "" {
t.Errorf("收件方是人类时 to_workspace 应为空,实得 %q", human)
}
if agent != ws {
t.Errorf("收件方是 Agent 时应继承会话 workspace %q实得 %q", ws, agent)
}
}