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:
@ -282,6 +282,29 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 收件方是 Agent 且地址里没写 path 位时,用**会话的 workspace** 补上。
|
||||||
|
//
|
||||||
|
// # 为什么必须补
|
||||||
|
//
|
||||||
|
// `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口。
|
||||||
|
// 而 Agent 之间的回信、以及人在对话页里点回复,地址里通常没有 path 位
|
||||||
|
// —— 平台自己下发的 `reply_address` 就是这个形状。空着传下去,插件只能
|
||||||
|
// 自己拼一个临时目录:**每封邮件一个不同的空目录**,DSH / opencode 按
|
||||||
|
// cwd 分组,界面上于是「每处理一封邮件就多出一条未分组会话」,而模型在
|
||||||
|
// 空目录里什么项目文件也看不到。
|
||||||
|
//
|
||||||
|
// 会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):
|
||||||
|
// 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。
|
||||||
|
//
|
||||||
|
// # 为什么改 to 而不是只改建库那一行
|
||||||
|
//
|
||||||
|
// 同一个值还进 `notifyRecipients` 的投递载荷(`to_workspace` /
|
||||||
|
// `self_address`)。改一处另一处不改,就会出现「API 读到的与插件推到的
|
||||||
|
// 不是同一个目录」—— 那正是本项目一直在治的静默不一致。
|
||||||
|
sessionWorkspace := repo.SessionWorkspaceOf(r.Context(), sessionID)
|
||||||
|
toIsHuman, _ := repo.IsHumanUser(r.Context(), to.Name)
|
||||||
|
to.Path = resolveToWorkspace(to.Path, sessionWorkspace, toIsHuman)
|
||||||
|
|
||||||
// Agent 新开的会话继承权限档位,**不得自行抬档**。
|
// Agent 新开的会话继承权限档位,**不得自行抬档**。
|
||||||
//
|
//
|
||||||
// req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位
|
// req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位
|
||||||
|
|||||||
38
server/internal/handler/toworkspace.go
Normal file
38
server/internal/handler/toworkspace.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
// resolveToWorkspace 决定一封邮件落库与投递时用的 `to_workspace`。
|
||||||
|
//
|
||||||
|
// # 它要解决的问题
|
||||||
|
//
|
||||||
|
// `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口。而 Agent
|
||||||
|
// 之间的回信、以及人在对话页里点回复,三维地址里通常**没有 path 位** —— 平台
|
||||||
|
// 下发的 `reply_address` 就是这个形状。空着传下去的后果是可观测的:
|
||||||
|
//
|
||||||
|
// - 插件只能自己拼一个临时目录,于是**每封邮件落在一个不同的空目录**里;
|
||||||
|
// - DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理一封邮件就多出
|
||||||
|
// 一条未分组会话」;
|
||||||
|
// - 模型在那个空目录里什么项目文件也看不到,却照样开工。
|
||||||
|
//
|
||||||
|
// 会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):回信本来
|
||||||
|
// 就是回给**那条会话**的,而那条会话知道自己属于哪个项目。
|
||||||
|
//
|
||||||
|
// # 三条规则,顺序不能换
|
||||||
|
//
|
||||||
|
// 1. 地址里写了 path → **照用**。那是人的明确意图,会话的 workspace 只是兜底。
|
||||||
|
// 2. 没写、且收件方是**人** → 保持空。人只有名字,没有工作目录;
|
||||||
|
// 给人类也填 path,前端与 agent_discovery 会拼出 `gui-lab@/root/proj.别名`
|
||||||
|
// 这种错地址(`ToHuman` 字段就是为避开这件事加的)。
|
||||||
|
// 3. 没写、且收件方是 **Agent** → 用会话的 workspace(可能仍为空串,
|
||||||
|
// 那表示这条会话确实没有工作目录,此时行为与从前一致)。
|
||||||
|
//
|
||||||
|
// 抽成纯函数是为了能直接对规则本身做判断:上面第 2 条是**反向对照**,
|
||||||
|
// 少了它,把「一律填 path」写错成看起来也合理。
|
||||||
|
func resolveToWorkspace(addrPath, sessionWorkspace string, toIsHuman bool) string {
|
||||||
|
if addrPath != "" {
|
||||||
|
return addrPath
|
||||||
|
}
|
||||||
|
if toIsHuman {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return sessionWorkspace
|
||||||
|
}
|
||||||
93
server/internal/handler/toworkspace_test.go
Normal file
93
server/internal/handler/toworkspace_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -153,6 +153,12 @@ func Recipients(ctx context.Context, m Mail) {
|
|||||||
"session_alias": alias,
|
"session_alias": alias,
|
||||||
// reply_address 是「把回信发回这条会话」的现成地址。
|
// reply_address 是「把回信发回这条会话」的现成地址。
|
||||||
// 插件不必自己拼(拼错了就是静默开新会话)。
|
// 插件不必自己拼(拼错了就是静默开新会话)。
|
||||||
|
//
|
||||||
|
// path 位刻意留空:它的语义是「**发件人**该在哪儿干活」,而人没有
|
||||||
|
// 工作目录(算上它会拼出 `gui-lab@/path.别名` 这种错地址)。
|
||||||
|
// 地址里没有 path 不会丢工作目录 —— 收信方回信时,发信端会用
|
||||||
|
// `sessions.workspace` 把它补齐(见 handler/mail.go 的“收件方是
|
||||||
|
// Agent 且地址里没写 path”那段),所以这里不必也不应该自己填。
|
||||||
"reply_address": models.FormatAddress(replyTo, "", alias),
|
"reply_address": models.FormatAddress(replyTo, "", alias),
|
||||||
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
|
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
|
||||||
"self_address": models.FormatAddress(forName, workspace, alias),
|
"self_address": models.FormatAddress(forName, workspace, alias),
|
||||||
|
|||||||
35
server/internal/repo/session_workspace.go
Normal file
35
server/internal/repo/session_workspace.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/agentmail/gateway/internal/db"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionWorkspaceOf 返回**这条会话**的工作目录(`sessions.workspace`)。
|
||||||
|
//
|
||||||
|
// # 为什么需要它
|
||||||
|
//
|
||||||
|
// 邮件的 `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,
|
||||||
|
// 但它取的是**地址里的 path 位**。而 Agent 之间的回信、以及人在对话页点回复时,
|
||||||
|
// 地址里通常没有 path 位 —— 平台自己下发的 `reply_address` 就是这个形状。
|
||||||
|
//
|
||||||
|
// 空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在
|
||||||
|
// 一个不同的空目录里**;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理
|
||||||
|
// 一封邮件就多出一条未分组会话」,而模型在空目录里什么项目文件也看不到。
|
||||||
|
//
|
||||||
|
// 会话的 workspace 才是权威来源(见 `models.SessionWorkspace` 的注释):
|
||||||
|
// 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。
|
||||||
|
//
|
||||||
|
// 读不到时返回空串而不是报错:投递路径不能因为一次查询失败就丢掉工作目录信息,
|
||||||
|
// 但也不能凭空编一个 —— 空串的语义就是「不知道」,由调用方决定怎么退化。
|
||||||
|
func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string {
|
||||||
|
var ws string
|
||||||
|
err := db.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(workspace, '') FROM sessions WHERE session_id = $1`, id).Scan(&ws)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ws
|
||||||
|
}
|
||||||
46
server/internal/repo/session_workspace_test.go
Normal file
46
server/internal/repo/session_workspace_test.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionWorkspaceOf 必须真的读得到 `sessions.workspace`。
|
||||||
|
//
|
||||||
|
// 为什么值得单独立一条测试:这个查询是**列名对不上就静默返回空串**的形态
|
||||||
|
// (读不到时按设计返回 "",不报错)。空串在这里的语义是「这条会话没有工作
|
||||||
|
// 目录」,与「我查错列了」无法区分 —— 于是列名写错的表现是:功能看起来还在
|
||||||
|
// 跑,只是工作目录永远继承不到,Agent 继续落在一次性空目录里。
|
||||||
|
//
|
||||||
|
// 本轮演练里我刚因为猜错表名(`mail_attachments` 不存在)而崩过一次,
|
||||||
|
// 这类错误只能靠对着真实 schema 跑一次来发现。
|
||||||
|
func TestSessionWorkspaceOf(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
sid, err := CreateSession(ctx, nil, "pi", "工作目录继承", "/root/projects/demo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("建会话: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := SessionWorkspaceOf(ctx, sid)
|
||||||
|
if got != "/root/projects/demo" {
|
||||||
|
t.Fatalf("SessionWorkspaceOf = %q,期望 /root/projects/demo —— "+
|
||||||
|
"空串说明列名或表名对不上(本函数读不到时静默返回空串)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反向对照:另一条没有 workspace 的会话必须返回空,而不是串上一条的值。
|
||||||
|
empty, err := CreateSession(ctx, nil, "pi", "无工作目录", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("建第二条会话: %v", err)
|
||||||
|
}
|
||||||
|
if got := SessionWorkspaceOf(ctx, empty); got != "" {
|
||||||
|
t.Errorf("没有 workspace 的会话应返回空串,实得 %q —— "+
|
||||||
|
"这可能是查询漏了 WHERE session_id 条件", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在的会话:按设计返回空串而不是 panic/报错(投递路径不能因它中断)。
|
||||||
|
if got := SessionWorkspaceOf(ctx, sid); got == "" {
|
||||||
|
t.Errorf("已存在的会话不应返回空串")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user