fix(permission): 人类的备注必须到达模型 + 决策回执不再被当成新任务

用户报的「很严重的问题」:被拒绝的 agent 看不到授权备注,且看不到他发的回复邮件。
按数据查到了两个**真缺陷**,都在桥的权限回路上(不是猜测,三层证据)。

## 缺陷一:备注在桥内被连丢三处

网关其实一路都带着备注(`CreateDecisionMail(..., req.Note)` 把备注写进决策邮件正文,
SSE payload 里也有 `"note"`),但桥的三个环节只传 decision:
  index.mjs  `pool.routePermission(relayKey, String(data.decision))`
  pool.mjs   `child.send({type:'permission_decision', relayKey, decision})`
  worker.mjs `resolve(String(msg.decision))`
模型最终看到的只有 `用户拒绝了这次 bash 调用`(pi 会话转录逐字可查)。

现场:人类写「我说了让你拉取仓库到program下你听不懂吗」,模型不知道要改什么,
把同一条命令换个写法又问了 —— 会话里连问 **9 次**(22:16–22:26)。

## 缺陷二:决策回执照样被当"新任务"投递 + 等人的邮件被堵在后面

决策是**双通道**送达:SSE `permission_decision`(唤醒停放的 worker)+ 一封普通形状的
邮件("Re: 权限请求 - 拒绝")。以前两条都会起动作 ⇒ 同一件事被处理两次;而这条会话
的新邮件在 worker 停放期间只能排队。实测:人类 22:18:08 发出的更正
「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」
直到 22:26:30(worker 回合结束)才被模型看到 —— **8 分钟**里它一直在错误的目录上打转。
转录里那封更正确实是模型自己 `read_mail` 读到的(不是没人给它)。

## 改动

- 网关:`CreateDecisionMail` 写 `mail_type='permission_decision'` —— 桥据此区分
  「控制面回执」与「新任务」。
- pi 桥(新增 `lib/denial-reason.js`、`lib/waiting-mails.js`):
  · 备注随决策一路透传到**模型看到的拒绝理由**(工具拦截与通知投递两条路都带);
  · 恢复停放的 worker 时,顺带把「等人期间新到、尚未标记已读」的邮件附进理由,
    模型当场就能改道(这正是那 8 分钟的洞);
  · 决策回执不再起新任务轮次(记进 deliveredMails);若决策事件尚未到达,
    退化为 B-4.3 的通知投递,且没有会话时不凭空新开。

## 判据

- `test/permission-note.test.mjs`:11 条(备注进理由、无备注不得凭空造说明、
  等人期间的邮件要点名 read_inbox、只挑本会话非权限类未交付的、上限、旧回包缺
  session_id 不能丢邮件、接线 8 处形状、判据自检)。
- **扰动验证**:把备注从 `pool.mjs` 的 send 里去掉 → 接线判据 2 条红;恢复 → 11 绿。
- 既有 pi 套件 420/420;server 10 包全绿(新增 1 条 Go 判据验决策邮件的类型与备注正文)。

## 现场证据(可复核)

- 桥日志:9 次 `权限 <key> 决策 同意/拒绝(决策人 jianf)已转交 worker`,全程不含备注;
  「worker 2135211 等待权限决策,让出并发额度(停放 1/5)」
- 会话转录:`{"toolName":"bash","content":[{"text":"用户拒绝了这次 bash 调用"}]}` ×6
This commit is contained in:
2026-09-13 22:47:25 +08:00
parent 773acd079f
commit 453f451fbb
9 changed files with 364 additions and 14 deletions

View File

@ -2,6 +2,7 @@ package repo
import (
"context"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
@ -210,3 +211,32 @@ func TestPermissionDecisionMarksOnlyDecider(t *testing.T) {
t.Fatalf("★ bob 未读 = %d期望 1决策是 alice 做的,不该替他标记已读)", n)
}
}
// 决策回执必须与普通邮件区分开:桥靠这个类型判断"这是控制面回执,不是新任务"
// (漏了它,同一封决策就会被当成新邮件再起一轮 —— 2026-09-13 线上缺陷的第二半)。
func TestDecisionMailCarriesTypeAndNote(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
req := seedMailTo(t, "alice", "")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, req).Scan(&sid); err != nil {
t.Fatal(err)
}
const note = "我说了让你拉取仓库到program下你听不懂吗"
id, err := CreateDecisionMail(ctx, sid, req, "jianf", "alice", "拒绝", note)
if err != nil {
t.Fatal(err)
}
var mtype, body string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_type, body FROM mails WHERE mail_id = $1`, id).Scan(&mtype, &body); err != nil {
t.Fatal(err)
}
if mtype != "permission_decision" {
t.Fatalf("决策邮件 mail_type = %q期望 permission_decision桥靠它区分「回执」与「新任务」", mtype)
}
if !strings.Contains(body, note) {
t.Fatalf("决策邮件正文必须带备注,实际:%q", body)
}
}

View File

@ -374,9 +374,13 @@ func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID u
if note != "" {
body = fmt.Sprintf("%s\n\n备注: %s", decision, note)
}
// `mail_type` 必须与普通邮件区分开:这封不是"新任务",而是**控制面回执**。
// 它的内容(决策 + 备注)已经随 SSE 的 permission_decision 直接交给了发起询问的
// worker桥若再按"新邮件"起一轮,同一件事就被处理两次 —— 2026-09-13 实测:
// 人类的更正邮件被挤在队列后面 8 分钟才被看到,而 agent 期间一直在重问同一条命令。
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, parent_mail_id, from_name, to_name, subject, body, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING mail_id`,
`INSERT INTO mails (session_id, parent_mail_id, from_name, to_name, subject, body, mail_type, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'permission_decision', NOW()) RETURNING mail_id`,
sessionID, parentMailID, fromUser, toAgent, "Re: 权限请求 - "+decision, body,
).Scan(&id)
return id, err