fix(permission): 权限决策者必须是人类,顺会话树查找

死锁现场:pi 的权限邮件 to_name = "pi"(决策者被解析成 Agent 自己),
SendToUser 找不到收件人 —— 请求发出去后无人可答,会话无声挂死。

新增 repo.NearestHumanInThread 三级查找:会话 owner → 最近人类发件人 →
最近人类收件/抄送方。发件人优先于收件人:主动说话的人更可能在关注这条线索。

不用递归 CTE 沿 parent_mail_id 上溯而按会话扫:权限请求常常不挂父邮件,
上溯会在第一跳就断。

没有人类可路由时**自动拒绝并给 Agent 可执行的替代建议**,而不是转给管理员
—— 管理员对这条 Agent 链的上下文一无所知,收到也无法判断。
409 返回结构化的 error/detail/suggestion/decider_was。

nearesthuman_test.go 4 子测试。
This commit is contained in:
2026-09-03 21:08:39 +08:00
parent 1767953331
commit edb4bd94fd
3 changed files with 280 additions and 0 deletions

View File

@ -510,3 +510,132 @@ func IsHumanUser(ctx context.Context, name string) (bool, error) {
`SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n)
return n > 0, err
}
// NearestHumanInThread 找出该会话上「最该为它点头的人」。
//
// 用途:权限询问的决策者是 Agent 时的救援路径。Agent 收不到 SendToUser
// (那是人类的 SSE 通道),桥的 await 也就永不 resolve —— 会话永久阻塞。
// 生产事故:pi 把任务派给自己的另一条会话,那条会话要跑 bash,
// 权限邮件发给了 "pi" 自己,整条线索卡死,只能改数据库救回来。
//
// 三级查找,按「谁最了解这件事」排序:
//
// 1. 会话 owner —— 人在界面上开的会话,归属明确
// 2. 最近一个往这条线索里**发过信**的人类 —— 派活的人
// 3. 最近一个作为**收件人或抄送**出现的人类 —— 至少他知道这件事在进行
//
// 找不到时返回空串(不是错误):调用方据此拒绝请求。这比转给一个对上下文
// 一无所知的管理员好 —— 他既不知道这个 bash 命令在做什么,
// 也不知道拒绝之后 Agent 该怎么绕过去。
//
// skipSelf 是发起询问的 Agent 名,永不作为决策者返回:它正是被卡住的那一方。
// 名字与人类用户名共用命名空间,所以这里也顺手挡住「Agent 名恰好等于某人类名」
// 这种配置错误。
func NearestHumanInThread(ctx context.Context, sessionID uuid.UUID, skipSelf string) (string, error) {
// 1. 会话 owner
if owner, err := SessionOwnerUsername(ctx, sessionID); err == nil && owner != "" && owner != skipSelf {
return owner, nil
}
// 2/3. 扫这条会话的邮件。发件人优先于收件人/抄送方:
// 发过信的人是主动参与者,被抄送的人可能只是旁观。
//
// 不用递归 CTE 沿 parent_mail_id 上溯:调用方只持有 session_id,
// 没有触发询问的那封锚点邮件,所谓「链」的起点本来就得靠猜。
// 而按会话扫还能覆盖分叉分支与断链(父邮件被删)的情形。
//
// **必须先把行读完再判定人类身份**:SQLite 连接池在测试与单文件库下
// 常常只有一条连接,rows 未关闭时再发一条查询会自我死锁(实测挂死 60s)。
type participants struct {
from string
others []string
}
rows, err := db.DB.QueryContext(ctx, `
SELECT m.from_name, m.to_name, m.cc_list
FROM mails m
WHERE m.session_id = $1
ORDER BY m.created_at DESC, m.mail_id DESC
`, sessionID)
if err != nil {
return "", err
}
var scanned []participants
for rows.Next() {
var from, to string
var ccRaw []byte
if err := rows.Scan(&from, &to, &ccRaw); err != nil {
rows.Close()
return "", err
}
scanned = append(scanned, participants{
from: from,
others: append([]string{to}, ccNames(ccRaw)...),
})
}
err = rows.Err()
rows.Close()
if err != nil {
return "", err
}
// 人类身份查询结果缓存:一条会话里同一个名字会出现很多次
human := map[string]bool{}
isHuman := func(name string) (bool, error) {
if v, ok := human[name]; ok {
return v, nil
}
v, err := IsHumanUser(ctx, name)
if err != nil {
return false, err
}
human[name] = v
return v, nil
}
var fallback string // 收件人/抄送里的人类,仅在没有人类发件人时使用
for _, p := range scanned {
if p.from != skipSelf {
if ok, err := isHuman(p.from); err != nil {
return "", err
} else if ok {
return p.from, nil // 最近的人类发件人,直接定案
}
}
if fallback != "" {
continue
}
for _, cand := range p.others {
if cand == "" || cand == skipSelf {
continue
}
if ok, err := isHuman(cand); err != nil {
return "", err
} else if ok {
fallback = cand
break
}
}
}
return fallback, nil
}
// ccNames 从 cc_list 的 JSON 里取出 name 位。
//
// 解析失败返回空切片而不是报错:抄送列表读不出来只该让这一封少几个候选,
// 不该让整个决策者查找失败 —— 那会把「会话卡死」换成「权限请求 500」。
func ccNames(raw []byte) []string {
if len(raw) == 0 {
return nil
}
var list []models.Address
if err := json.Unmarshal(raw, &list); err != nil {
return nil
}
out := make([]string, 0, len(list))
for _, a := range list {
if a.Name != "" {
out = append(out, a.Name)
}
}
return out
}