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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user