用户 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 全绿。
254 lines
7.0 KiB
Go
254 lines
7.0 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// 停用是可逆的「归档」,不是删除。这组测试钉住三件事:
|
||
// 停用后从候选里消失、密钥被撤销、重新注册不能复活它。
|
||
|
||
func TestSetAgentDisabledHidesFromCandidates(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
for _, n := range []string{"keeper", "goner"} {
|
||
if err := CreateOrUpdateAgent(ctx, n, "s", "test", nil); err != nil {
|
||
t.Fatalf("注册 %s: %v", n, err)
|
||
}
|
||
}
|
||
|
||
if _, err := SetAgentDisabled(ctx, "goner", true); err != nil {
|
||
t.Fatalf("停用: %v", err)
|
||
}
|
||
|
||
// 默认列表(地址补全、GET /agents、可授权范围都走这条)不含已停用的
|
||
got, err := ListAgents(ctx, "")
|
||
if err != nil {
|
||
t.Fatalf("ListAgents: %v", err)
|
||
}
|
||
names := map[string]bool{}
|
||
for _, a := range got {
|
||
names[a.Name] = true
|
||
}
|
||
if names["goner"] {
|
||
t.Error("已停用的 Agent 仍出现在默认列表里 —— 人会把任务派给一个不会响应的地址")
|
||
}
|
||
if !names["keeper"] {
|
||
t.Error("停用一个把别的也弄没了")
|
||
}
|
||
|
||
// statusFilter="all" 时要能看到 —— 那是管理页恢复它的唯一入口
|
||
all, err := ListAgents(ctx, "all")
|
||
if err != nil {
|
||
t.Fatalf("ListAgents(all): %v", err)
|
||
}
|
||
found := false
|
||
for _, a := range all {
|
||
if a.Name == "goner" {
|
||
found = true
|
||
if a.Status != "disabled" {
|
||
t.Errorf("状态应为 disabled,实际 %q", a.Status)
|
||
}
|
||
}
|
||
}
|
||
if !found {
|
||
t.Error("statusFilter=all 也看不到已停用的,就再也无法恢复它了")
|
||
}
|
||
}
|
||
|
||
func TestSetAgentDisabledRevokesKeys(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||
t.Fatalf("注册: %v", err)
|
||
}
|
||
admin := seedAdminForTest(t, ctx)
|
||
for i := 0; i < 2; i++ {
|
||
if _, err := CreateAgentKey(ctx, "bot", "permanent", "k", 0, admin, ""); err != nil {
|
||
t.Fatalf("建密钥: %v", err)
|
||
}
|
||
}
|
||
|
||
revoked, err := SetAgentDisabled(ctx, "bot", true)
|
||
if err != nil {
|
||
t.Fatalf("停用: %v", err)
|
||
}
|
||
if revoked != 2 {
|
||
t.Errorf("应撤销 2 把密钥,实际 %d", revoked)
|
||
}
|
||
|
||
keys, err := ListAgentKeys(ctx, "bot")
|
||
if err != nil {
|
||
t.Fatalf("ListAgentKeys: %v", err)
|
||
}
|
||
if len(keys) != 0 {
|
||
t.Errorf("停用后仍留着 %d 把密钥 —— 插件还能用它调 /mail/send,"+
|
||
"停用的语义是「不再参与工作」而不只是「不出现在补全里」", len(keys))
|
||
}
|
||
}
|
||
|
||
func TestDisabledAgentCannotReRegister(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||
t.Fatalf("首次注册: %v", err)
|
||
}
|
||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||
t.Fatalf("停用: %v", err)
|
||
}
|
||
|
||
// 插件启动时会重新注册。不拒的话 status 被写回 online,停用等于没做。
|
||
err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil)
|
||
if !errors.Is(err, ErrAgentDisabled) {
|
||
t.Fatalf("已停用的 Agent 重新注册应当被拒,实际 err=%v", err)
|
||
}
|
||
|
||
disabled, err := AgentDisabled(ctx, "bot")
|
||
if err != nil {
|
||
t.Fatalf("AgentDisabled: %v", err)
|
||
}
|
||
if !disabled {
|
||
t.Error("注册尝试把停用状态冲掉了")
|
||
}
|
||
}
|
||
|
||
func TestHeartbeatDoesNotReviveDisabledAgent(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||
t.Fatalf("注册: %v", err)
|
||
}
|
||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||
t.Fatalf("停用: %v", err)
|
||
}
|
||
|
||
// 心跳是 30 秒一次的。不排除 disabled 的话停用最多维持半分钟。
|
||
if _, err := HeartbeatAgent(ctx, "bot"); err != nil {
|
||
t.Fatalf("心跳本身不该报错: %v", err)
|
||
}
|
||
|
||
disabled, _ := AgentDisabled(ctx, "bot")
|
||
if !disabled {
|
||
t.Error("心跳把已停用的 Agent 改回在线了")
|
||
}
|
||
}
|
||
|
||
func TestRestoreAgentGoesOfflineNotOnline(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||
t.Fatalf("注册: %v", err)
|
||
}
|
||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||
t.Fatalf("停用: %v", err)
|
||
}
|
||
if _, err := SetAgentDisabled(ctx, "bot", false); err != nil {
|
||
t.Fatalf("恢复: %v", err)
|
||
}
|
||
|
||
all, _ := ListAgents(ctx, "all")
|
||
for _, a := range all {
|
||
if a.Name != "bot" {
|
||
continue
|
||
}
|
||
// 恢复成 online 会让界面显示一个其实没在跑的 Agent 为在线;
|
||
// 它是否真的活着由下一次心跳决定。
|
||
if a.Status != "offline" {
|
||
t.Errorf("恢复后应为 offline,实际 %q", a.Status)
|
||
}
|
||
}
|
||
|
||
// 恢复后能重新注册
|
||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||
t.Errorf("恢复后应当能重新注册: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSetAgentDisabledUnknownAgent(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
_, err := SetAgentDisabled(ctx, "nope", true)
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
t.Errorf("停用不存在的 Agent 应回 ErrNoRows,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// 停用不得动邮件与会话 —— 往来里有一半是人自己写的。
|
||
func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||
t.Fatalf("注册: %v", err)
|
||
}
|
||
sid, err := CreateSession(ctx, nil, "bot", "一件事", "/tmp/ws")
|
||
if err != nil {
|
||
t.Fatalf("建会话: %v", err)
|
||
}
|
||
if _, err := CreateMail(ctx, sid, nil,
|
||
"human", "", "bot", "/tmp/ws", "主题", "正文", nil); err != nil {
|
||
t.Fatalf("建邮件: %v", err)
|
||
}
|
||
|
||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||
t.Fatalf("停用: %v", err)
|
||
}
|
||
|
||
mails, err := ListInbox(ctx, "bot", "all", "", 10)
|
||
if err != nil {
|
||
t.Fatalf("ListInbox: %v", err)
|
||
}
|
||
if len(mails) != 1 {
|
||
t.Errorf("停用把邮件删了:剩 %d 封。那些往来里有一半是人自己写的", len(mails))
|
||
}
|
||
}
|
||
|
||
// 模型范围与平台会话镜像也保留:恢复后不必重配。
|
||
func TestSetAgentDisabledKeepsModelScope(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||
t.Fatalf("注册: %v", err)
|
||
}
|
||
if err := SetAllowedModels(ctx, "bot", []ModelRef{{Provider: "p", Model: "m"}}); err != nil {
|
||
t.Fatalf("设范围: %v", err)
|
||
}
|
||
|
||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||
t.Fatalf("停用: %v", err)
|
||
}
|
||
|
||
allowed, err := ListAllowedModels(ctx, "bot")
|
||
if err != nil {
|
||
t.Fatalf("ListAllowedModels: %v", err)
|
||
}
|
||
if len(allowed) != 1 {
|
||
t.Errorf("停用把模型范围清了,恢复后管理员得重配一遍:%+v", allowed)
|
||
}
|
||
}
|
||
|
||
// seedAdminForTest 插一个管理员并返回它的 user_id(CreateAgentKey 要 created_by)。
|
||
func seedAdminForTest(t *testing.T, ctx context.Context) uuid.UUID {
|
||
t.Helper()
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`INSERT INTO users (username, display_name, password_hash, role)
|
||
VALUES ('key-admin', 'Admin', 'x', 'admin') RETURNING user_id`).Scan(&id)
|
||
if err != nil {
|
||
t.Fatalf("seed admin: %v", err)
|
||
}
|
||
return id
|
||
}
|