fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)

用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」

# 缺陷(生产实测,2026-09-26)

在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)

根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。

# 三条守卫全部只覆盖自动转发,防不住这个

| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |

# 服务端

· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
  三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
  后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
  ★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
  (session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
  发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
  所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
  "我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
  `pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
  而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。

# 三个插件(pi / opencode / dsh)

· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
  后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
  `sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
  `workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。

# 实测凭据

· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
  (改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)

# 判据

· `server/internal/repo/workspace_scope_test.go`(3 条):
  两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
  未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
  取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
  现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿

全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
This commit is contained in:
2026-09-26 07:44:33 +08:00
parent b85f2b26f0
commit 7634be8966
22 changed files with 957 additions and 179 deletions

View File

@ -55,7 +55,73 @@ func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, wor
return err
}
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
/*
UnreadWorkspaces 列出"这个 Agent 在哪些工作区有未读"。
# 为什么要它,而不是让心跳收一个 workspace 参数
心跳是**进程级**的(一个桥进程同时服务所有工作区),而收件箱是**worker 级**的
(每个 worker 手上只有一封信,信封上有明确的 path 位)。在进程级强制要求
workspace 是概念错配 —— 它没有一个"我的工作区"可言。
但 `pending_mails` 是桥的补投判据:若它是一个跨工作区的总数,桥就不知道该去
**哪个工作区**补投。⇒ 心跳返回这个清单,桥逐个工作区去读(见 catchUp)。
这同时修掉一个隐蔽问题:补投原先调 `/mail/inbox?status=unread`(不带收窄),
按当时的语义会列出**所有工作区**的未读并逐封重放 —— 在 mc 干活时会去补投
agentmail 的信。
*/
func UnreadWorkspaces(ctx context.Context, agentName string) ([]string, error) {
if err := requireReader(agentName); err != nil {
return nil, err
}
rows, err := db.DB.QueryContext(ctx, `
SELECT s.workspace, COUNT(*) AS n
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND `+unreadFor("$1")+`
AND s.status <> 'archived'
-- ★ 只报**可寻址**的工作区(绝对路径)。
--
-- 历史库里存在 workspace 为相对路径的行(早期以 "pi@root" 寻址留下的
-- 测试会话)。收件箱接口要求绝对路径,把它们放进清单只会在桥侧撞 400
-- —— 我实测就撞到了:"补投工作区 root 失败: HTTP 400"。
--
-- 过滤放在这里而不是让桥去试错:这个清单的语义是"**能去补投**的工作区",
-- 列出不可寻址的等于给调用方递一个注定失败的任务。
AND s.workspace LIKE '/%'
GROUP BY s.workspace
ORDER BY n DESC`, agentName)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var ws string
var n int
if err := rows.Scan(&ws, &n); err != nil {
return nil, err
}
out = append(out, ws)
}
return out, rows.Err()
}
/*
TouchAgentLastSeen 只刷新"我还活着",**不**算未读数。
# 为什么要把它拆出来
原先 middleware 在**每一个**已认证请求上都调 HeartbeatAgent(它会顺手算
`CountUnread` 并丢掉返回值)—— 那是白算一次全表扫描,而且现在 `CountUnread`
还需要工作区,而 middleware 那一层拿不到(请求体还没解析)。
⇒ 拆成两件事:心跳副作用(只更新 last_seen)留在 middleware;
"这个工作区还有多少未读"由心跳 handler 按请求体里的 workspace 算。
*/
func TouchAgentLastSeen(ctx context.Context, agentName string) error {
// 只把【非停用】的 Agent 标成在线。
//
// 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online
@ -64,10 +130,21 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
`UPDATE agents SET last_seen = NOW(), status = 'online'
WHERE agent_name = $1 AND status <> 'disabled'`,
agentName)
if err != nil {
return err
}
// HeartbeatAgent 刷新在线状态并返回**该 Agent 的未读总数**。
//
// ★ 这里是**全局**口径(跨工作区),与 ListInboxScoped 不同 —— 原因见
//
// UnreadWorkspaces 上面那段:心跳是进程级,收件箱是 worker 级。
// 桥拿到这个总数后,用 `pending_workspaces` 清单逐工作区去补投,
// 两边合起来才是"有没有信、在哪个工作区"。
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
if err := TouchAgentLastSeen(ctx, agentName); err != nil {
return 0, err
}
return CountUnread(ctx, agentName)
return CountUnread(ctx, agentName, "")
}
// AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false ——
@ -504,6 +581,44 @@ func requireReader(reader string) error {
return nil
}
/*
checkWorkspace 校验 workspace 的形状(**允许为空**)。
# 空与非空各是什么语义(两条不同的入口,别混)
· **非空** = 只列该工作区的信。**Agent 侧必须非空**(Handler 层强制)。
· **空** = 不过滤,列该名字的全部。**人类侧就是这个**:一个人跨工作区,
WebUI 把结果按 `session_workspace` **分组显示**(`MailList.tsx:167`),
而不是只给一个工作区 —— 强行让人也带工作区,等于让人在多个工作区之间反复切。
⇒「必需」这条约束放在 **Handler**(`GetInbox` / `MarkInboxRead` / `HeartbeatAgent`),
不是这里:它是**接口契约**,不是数据层不变量。放 repo 会让人类那条路也没法用,
而人类侧并没有"我处在哪个工作区"这个概念。
# 为什么 Agent 必须带工作区
Agent 的收件箱原先只按名字过滤(`WHERE m.to_name = $1`),于是 `pi` 这个名字下
**所有工作区**的信混成一个池子。生产实测:在 `mc` 工作区干活的 pi 读收件箱拿到
200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改
agentmail 的代码,把手上 mc 的活丢在一边(用户当场问「你怎么干着干着修
agentmail 去了?」)。
三维地址是 `name@path.session` —— **path 位本来就该参与寻址**。收件箱侧此前
完全没用它,那不是"另一种语义",是没兑现契约。
*/
func checkWorkspace(workspace string) error {
w := strings.TrimSpace(workspace)
if w == "" {
return nil // 人类侧:不过滤,由 WebUI 按工作区分组显示
}
// 绝对路径:相对路径在服务端无法解释,且不同调用方 cwd 不同 ⇒ 拼出来必然对不上。
if !strings.HasPrefix(w, "/") {
return fmt.Errorf("workspace 必须是绝对路径,收到 %q", w)
}
return nil
}
// unreadFor 返回"$n 这个读者看这封邮件是未读"的谓词;`m` 必须是 mails 的别名。
func unreadFor(arg string) string {
return `(m.status <> 'archived' AND NOT EXISTS (
@ -595,15 +710,23 @@ func MarkMailRead(ctx context.Context, id uuid.UUID, reader string) error {
原函数保持原语义(不带会话 = 整个 Agent 的收件箱),新增带会话的变体 ——
老调用点一个都不用改。
*/
func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) {
return ListInboxScoped(ctx, agentName, status, limit, uuid.Nil)
func ListInbox(ctx context.Context, agentName, status, workspace string, limit int) ([]models.Mail, error) {
return ListInboxScoped(ctx, agentName, status, workspace, limit, uuid.Nil)
}
// ListInboxScoped 与 ListInbox 相同,但 `sessionID` 非零时只列该会话的邮件。
func ListInboxScoped(ctx context.Context, agentName, status string, limit int, sessionID uuid.UUID) ([]models.Mail, error) {
//
// `workspace` 非空时收窄到该工作区(Agent 侧 Handler 强制必填;人类侧为空):只列属于该工作区的会话里的信。
// 判据用 `s.workspace`(会话的权威工作区)而不是 `m.to_workspace`:
// 后者是**这封信**的信封字段,可能是抄送、可能是历史遗留;而"这条线索属于哪个
// 工作区"是会话的属性,一处定死才不会两种答案。
func ListInboxScoped(ctx context.Context, agentName, status, workspace string, limit int, sessionID uuid.UUID) ([]models.Mail, error) {
if err := requireReader(agentName); err != nil {
return nil, err
}
if err := checkWorkspace(workspace); err != nil {
return nil, err
}
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
@ -618,7 +741,15 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND s.status <> 'archived'`
// ★ 工作区收窄(必需):`pi` 在两个工作区各有一条收件箱,互不可见。
//
// 用 EXISTS 而不是再 JOIN 一次 sessions:s 已经在上面 JOIN 过了,
// 这里直接把条件写进 WHERE 即可(同一条 s)。
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
if sessionID != uuid.Nil {
// 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明)
args = append(args, sessionID)
@ -633,7 +764,8 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
} else if status == "read" {
q += ` AND NOT ` + unreadFor("$1") + ` AND m.status <> 'archived'`
} else {
q += ` AND m.status = $2`
// ★ 占位符必须是**动态序号**:$2 现在被 workspace 占了。
q += fmt.Sprintf(` AND m.status = $%d`, len(args)+1)
args = append(args, status)
}
}
@ -685,19 +817,41 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s
return mails, rows.Err()
}
func CountUnread(ctx context.Context, agentName string) (int, error) {
func CountUnread(ctx context.Context, agentName, workspace string) (int, error) {
return CountUnreadScoped(ctx, agentName, workspace, uuid.Nil)
}
// CountUnreadScoped 与 CountUnread 相同,但 `sessionID` 非零时只数那条会话。
//
// ★ 必须与 ListInboxScoped **同一个收窄口径**:桥的补投判据是
//
// `pending_mails = CountUnread` —— 两者口径不一致时,列表看不到的信会一直
// 被算成"还有未读",桥每次心跳都重放一遍(这是设计文档里记过的那个坑)。
func CountUnreadScoped(ctx context.Context, agentName, workspace string, sessionID uuid.UUID) (int, error) {
if err := requireReader(agentName); err != nil {
return 0, err
}
if err := checkWorkspace(workspace); err != nil {
return 0, err
}
var count int
err := db.DB.QueryRowContext(ctx, `
q := `
SELECT COUNT(*)
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND `+unreadFor("$1")+`
AND s.status <> 'archived'
`, agentName).Scan(&count)
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND ` + unreadFor("$1") + `
AND s.status <> 'archived'`
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
if sessionID != uuid.Nil {
args = append(args, sessionID)
q += fmt.Sprintf(` AND m.session_id = $%d`, len(args))
}
err := db.DB.QueryRowContext(ctx, q, args...).Scan(&count)
return count, err
}
@ -1822,21 +1976,31 @@ func MarkMailsReadFor(ctx context.Context, recipient string, ids []uuid.UUID) (i
//
// 排除已归档会话:那些邮件在收件箱里根本看不到,
// 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。
func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) {
return MarkAllInboxReadForSession(ctx, recipient, uuid.Nil)
func MarkAllInboxReadFor(ctx context.Context, recipient, workspace string) (int, error) {
return MarkAllInboxReadForSession(ctx, recipient, workspace, uuid.Nil)
}
// MarkAllInboxReadForSession 只标掉某条会话里发给 recipient 的未读。
//
// 为什么需要:Agent 的「不给 mail_ids,全部标掉」在会话驱动的 worker 里会跨会话
// 误伤(见 ListInboxScoped 上面那段说明)。不带 sessionID(uuid.Nil)时是旧语义。
func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID uuid.UUID) (int, error) {
scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')`
func MarkAllInboxReadForSession(ctx context.Context, recipient, workspace string, sessionID uuid.UUID) (int, error) {
if err := checkWorkspace(workspace); err != nil {
return 0, err
}
// ★ 工作区收窄:与 ListInboxScoped **同一个谓词** —— 列表看不到的信却被
// "全部标掉"标掉,就是静默丢信(session_scope_test.go 记过这个形状)。
wsFilter := ""
args := []any{recipient}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
wsFilter = fmt.Sprintf(" AND workspace = $%d", len(args))
}
scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)`
if sessionID != uuid.Nil {
scope += ` AND m.session_id = $2`
args = append(args, sessionID)
scope += fmt.Sprintf(` AND m.session_id = $%d`, len(args))
}
if err := markReadFor(ctx, recipient, scope, args...); err != nil {
return 0, err
@ -1847,11 +2011,14 @@ func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID
upd := `UPDATE mails SET status = 'read'
WHERE status = 'unread'
AND (to_name = $1 OR ` + db.CCHas("cc_list", 1) + `)
AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')`
AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)`
updArgs := []any{recipient}
if strings.TrimSpace(workspace) != "" {
updArgs = append(updArgs, workspace)
}
if sessionID != uuid.Nil {
upd += ` AND session_id = $2`
updArgs = append(updArgs, sessionID)
upd += fmt.Sprintf(` AND session_id = $%d`, len(updArgs))
}
res, err := db.DB.ExecContext(ctx, upd, updArgs...)
if err != nil {