Commit Graph

10 Commits

Author SHA1 Message Date
7634be8966 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 全绿。
2026-09-26 07:44:33 +08:00
6e4bcd66be fix(网关): 附件竞态回滚一件都退不掉 —— BindRelayMail 早于 attachAll 导致撞外键
## 缺陷

`/mail/send` 的回滚块注释写着「三件事都要退:邮件本身、本次往返预算、relay
幂等键」,但实际**一件都退不掉**:

    relayed_mails.mail_id REFERENCES mails(mail_id)   -- 无 CASCADE
    DeleteMailByID  : DELETE FROM mails WHERE mail_id = $1
    ReleaseRelay    : DELETE FROM relayed_mails WHERE ... AND mail_id IS NULL

`BindRelayMail` 原来在 `attachAll` **之前**执行,所以走到回滚块时该键已经绑上了:

  - `DeleteMailByID` 撞外键失败(生产 DSN 有 `foreign_keys(1)`)⇒ 邮件留在库里;
  - `ReleaseRelay` 的 WHERE 是 `mail_id IS NULL`,对已绑定的行是 no-op ⇒ 键没退。

后果与注释想避免的正好相反:发件方收到 4xx 会重试,收件方看到那封残余邮件 ——
两封。

## 修法

把 `BindRelayMail` 挪到回滚块**之后**。绑定是纯审计关联(`RelayKeyForMail`
反查用),`attachAll` 与 `notifyRecipients` 都不读它(`models.Mail` 零 relay
字段),所以推迟没有副作用。

## 判据

`TestMailSendRollbackRemovesMailAndRelayKey`:同一个 `attachment_id` 传两次 ——
`checkAttachable` 逐条查时都还没挂载(都通过),`attachAll` 的原子 UPDATE 在
第二条改到 0 行 ⇒ 409 ⇒ 回滚。这样无需真并发就能确定性地走到那条路径。

三条断言:① 触发条件成立(409,否则用例会静默退化成空跑);② 邮件没留下;
③ 幂等键退回去了。已变异验证:把 `BindRelayMail` 挪回 `attachAll` 之前 ⇒ 用例红
(mails=1、relays=1),正是原缺陷的形状。

`me.go` 的同名回滚不受影响:人类发信不走 relay,且 `CreateMail` 不写
`mail_reads`,实测 `DeleteMailByID` 返回 nil。

`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
2026-09-25 05:50:08 +08:00
34a15dc09c fix(网关): 占住幂等键后的早退路径没退键 —— 永久占键、重试永远 duplicate
## 缺陷

`ClaimRelay` 成功之后、邮件落库之前有若干条早退路径,只有**部分**在 return 前
调了 `ReleaseRelay`。漏掉的那些会把 (agent_name, relay_key) 永久占住:
之后同一上游消息的重试拿到 `ErrRelayDuplicate` ⇒ 返回 200 `duplicate_relay`
(文案是「该上游消息已转发过,本次调用未产生新邮件」),
而那条消息**从未发出去** —— 响应在陈述一件没发生的事。

野外实例(现库仍在,`relayed_mails` 唯一一行 `mail_id IS NULL`):

    agent_name = zcode
    relay_key  = no-such-session-0000:toolu-nohuman-1789193173578
    created_at = 2026-09-12 06:06:13

它的 session 位不是 UUID,于是 `RequestPermission` 走到
`uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)直接 return,
没有退键。该行从 09-12 留到现在。

`mail.go` 同类:`ClaimRelay`(:396)之后 `ConsumeSessionBudget` 返回
非「额度耗尽」错误时(:433)也没退键。

## 修法

不在各 return 前逐个补 `ReleaseRelay` —— 那正是缺陷的成因(漏一处就是一个静默的
永久占键,而漏掉的那处不会有任何报错)。改为占键成功后立刻 `defer` 一次释放,
让"还键"成为该作用域的唯一出口不变量。

成功路径不需要额外开关:`ReleaseRelay` 的 WHERE 含 `mail_id IS NULL`,
`BindRelayMail` 一旦成功该行就不再匹配,defer 自然什么都不做。

## 判据(两条独立用例,缺一即放过一类坏实现)

`TestRequestPermissionReleasesRelayKeyOnEarlyExit` —— 失败路径:早退后同一个
relay_key 必须还能再次占用(键被还回来了)。

`TestRequestPermissionClaimsRelayKeyOnSuccess` —— 成功路径:同一询问重复投递
必须被幂等键挡住,且键确实绑定到了第一封邮件上。

两条必须分开:早退时"正确实现"与"把 ClaimRelay 换成空操作"的坏实现在表里
**观测等价**(都留下一个空键),单条用例无法同时钉住"该退的时候退"和
"该占的时候占"。已逐个变异验证:

    基线(缺陷代码)  → 早退用例红、成功用例绿
    早退处补 Release  → 两条全绿
    ClaimRelay 置空   → 成功用例红(早退用例仍绿,符合预期)
    BindRelayMail 去掉 → 两条全红

`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
2026-09-25 05:41:57 +08:00
7e1adfd731 revert(过度矫正): 撤回"目录合不合法"的两道门槛 —— 错在投递,不在值
用户否掉了我上一步的方向:「?拒收那个目录干啥?明明是你的网关投递逻辑造成的错误」。

他是对的。/home 是**合法目录**,平台不该替用户裁定"哪个目录算工作目录":
网关地址受理处的 400、建议接口的过滤、桥侧的同类闸(revert 141003d)全部撤回。
判据随之删除 —— 判据不该给错误的实现护航(那会让错的东西看起来有保障)。

保留的是**真修复**(ad1f3f1,已在线):
- 会话解析按 (path, 别名):错投的根因是解析只看别名、path 被当提示;
- 唯一索引 (path, 别名);无 path 时"唯一才认,否则报歧义";
- 毒数据清理(/home 污染值 0|0|0)。
核心回归判据 TestFindNamedSessionForRequiresPath 保留。

仍未修(用户指的那一处):SSE 投递只按 name 广播 —— 同名不同 path 的客户端谁接到谁处理。
下一 commit:投递也带会话身份,只投给声明了该会话的客户端。
2026-09-15 12:54:25 +08:00
ad1f3f14c3 fix(session): 会话身份 = (path, 别名) —— 修用户报的错投,并堵住 /home 这类毒 path
用户 2026-09-15 的订正:「agent 平台的 session 是和 path 绑定的,path+session 才能
指定到准确的 agent,而授权也是对 session 授权,而不是整个 agent」;随后报了一个
严重错投:「工作区在 TrueAgent 的 pi 客户端,发送邮件给 dsh 被投递给了工作区在 home
的客户端」。

根因是四处叠加(都有实测证据):
1. 解析只按别名:`WHERE session_alias = $1`,path 被当可选提示 ⇒ 两条不相干的
   线索能落进同一会话;
2. 别名全局唯一 ⇒ path 在解析时冗余,进一步被忽略;
3. 桥按来信的 to_workspace 起 worker(实测日志 `新建 pi 会话 …(cwd=/home)`);
4. 投递(SSE)只按 name 广播。

本提交改 1/2 + 堵源头:
- 解析按 (path, 别名):给了 path 必须两者同时命中,对不上就是 ErrSessionNotFound
  (**绝不**退回按别名找);没给 path 则要求该别名唯一,多条时 ErrSessionAmbiguous
  (不许掷骰子);
- 唯一索引从 `(session_alias)` 改成 `(COALESCE(workspace,''), session_alias)`
  (两方言 + 显式 DROP 旧索引;EnsureSessionAlias 靠唯一索引判撞名,自动变成按 path);
- 新 IsPlausibleWorkspace(与目录建议**同源**一条规则):地址受理处拒收不像工作目录的
  path(`/home`、`/root`、`~/.pi/mail-sessions/…`、顶层挂载点),并给出可执行文案;
- 建议接口的候选过滤改用同一条规则(只留具体目录;祖先容器让位给更具体的候选)。

判据:新增/改写 4 组,全部做变异验证 —— 三条核心变异(解析退回按别名找 / 歧义时
掷骰子 / 去掉「深度≥2」)全部变红。过程中判据还抓到自己一个漏洞:
IsPlausibleWorkspace("/home") 在旧规则下是 true(/home 不是 root 的家目录)。

★ 语义变化(会让旧测试红):不同 path 下的同名别名现在是两条独立会话,不再"撞名"。
TestAdoptHandlesAliasCollision 已按新模型重写为两侧(同 path 才加后缀)。

未做:桥侧仍按来信 to_workspace 起 cwd(下一步);线上的库要等下一次启动才跑迁移。
2026-09-15 12:20:28 +08:00
a684d479bb fix(mail)!: agent 发信不再把 agent 名当工作区存进 from_workspace
用户报「这个 zcode@zcode 转发生成的地址绝对有问题」的**写入侧根因**:
mail.go 的 agent 发信路径传的是 `agentName, agentName` —— 第二个参数是 from_workspace,
于是近两天 zcode 10 封、homeagent/opencode 全部把**自己的名字**存成了工作区
(人类路径 me.go 那一格是空串,所以只有 agent 的信这样;pi 399 封有 4 种值、dsh 116 封 3 种
则是历史与真实路径混着的状态)。

改法(一处):from_workspace = 该会话的工作区 —— 地址里带了 path 就用它,
否则回查 SessionWorkspaceOf(空串语义是「不知道」);再兜一层"等于 agent 名就置空"。
配套的显示侧修复(d9d81b9)已让 `name@name` 不再出现,并对**存量**行生效
(path==name 时当"不知道"丢弃,改用会话反查)——所以历史数据不需要回填。

生产实测(部署后新写入一封):
  修复前该格 = "pi"(或 "zcode");现在 = "/home/program/agentmail"(会话工作区)
判据:显示侧 TestQuoteBodySenderAddressNeverNameAtName 已钉住引用块;
本改动为写入侧,用**线上真发一封 + 读库对照**验证(handler 包无发信测试夹具,
不硬加一条只钉形状的假判据)。
2026-09-15 09:47:08 +08:00
552fbc731e fix(inbox): read_inbox 按会话收窄 —— 修「不同 session 的 agent 都能看到全部邮件」
用户问:「你之前不是说你已经处理了不同 session 的 agent 都可以看到全部邮件的
问题了吗?」——**我得先纠正事实:上一轮我只做了诊断并问要不要动手,没有实施。**
这是我的表述问题(把"已定位并给了方案"说成了像"已处理")。现在实施。

## 缺陷

`read_inbox` 是**按 Agent** 的:列的是该 Agent 的全部未读(含别的会话的来信),
并按契约把列出来的都标成已读 ⇒ A 会话的 worker 标掉 B 会话的未读。平时看不出来
(SSE 事件在途时队列兜着),但桥重启/漏事件后的补投判据是 `?status=unread` ——
被标掉的那封**再也不会补投** ⇒ 静默丢信。现场实例:另一条会话的来信在
`mail_reads` 里的 reader=pi、时间正是我读自己收件箱的那一刻。

## 改动

- **网关**:`GET /mail/inbox` 与 `POST /mail/read` 支持可选 `session_id`。
  不带 = 旧语义(整个 Agent 的收件箱,浏览器/脚本仍可用);带了就只在这条会话内
  列与标。`ListInbox` / `MarkAllInboxReadFor` 保持原签名并委托给新变体 ——
  老调用点一个都不用改。
- **pi 桥**:`read_inbox` 把自己那条会话拼进 URL(worker 通过闭包把**邮件会话 id**
  递给工具,而不是在启动时取快照)。

## 判据

- repo 三条:列表按会话收窄(含"不带会话时两条都在"的反向对照)、
  ★"标会话 A 不动会话 B"、会话内计数与列表口径一致(否则界面会出现"徽标 2、列表 1")。
- handler/网关:非法 `session_id` ⇒ 400(不静默忽略)。
- pi 接线三条(URL 拼了收窄、worker 递了 id、判据自检:旧写法必须判红)。
- 线上只读 E2E:两条真实会话 A/B 列表**无交集**、不带会话能列出全部、非法 id 400。

## 过程中测试当场抓到"只改了一半"

`MarkAllInboxReadForSession` 里插 `mail_reads` 的语句我加了会话条件,
**刷新冗余列的 UPDATE 忘了加** ⇒ 返回的"标掉几封"变成 2(应 1)。
判据一眼看出来了 —— 这类"改一半"正是这次要防的。

## 范围(诚实说明)

另外四家桥(dsh/opencode/zcode/homeagent)的 `read_inbox` 工具签名里**没有会话上下文**
(`execute(args)` / `execute(args, ctx)` 各不相同),要按各自框架的上下文 API 接线,
不是一行改动 ⇒ **未做**,列为待办(位置已定位)。所以:pi 上这个缺陷已消除,
另外四家仍在。

## 部署

网关已部署并线上验证;pi 桥的部署**延迟到本轮结束后 150 秒**执行
(重启 pi 桥会掐掉我自己这一轮 —— 之前真发生过),日志
`/var/log/agentmail-pi-redeploy.log`,可用 `node deploy/check-deploy-drift.mjs` 核对。
2026-09-14 09:19:25 +08:00
1619399470 fix(gateway): 已读改为**按读者**记录 —— 修掉"别人读掉,我就看不到"
用户报的那句 dsh 自述("收件箱列表未展示它,直接按 mail_id 读取成功")不是插件问题,
是网关的已读模型:`mails.status` 是**邮件级**的一个列,任何收件人读掉,对所有收件人
(含抄送)都变成已读 —— 全库没有任何按人记录已读的表,我查过 schema 与迁移文件。

实测复现(两个人类用户、一封共享邮件,排除 Agent 干扰):
  gui-lab 读掉 → gui-lab 未读清空(应当)→ **jianf 的未读也没了**(错误)
  而 jianf 的 `status=all` 里仍在 ⇒ 是已读语义问题,不是送达问题。
线上那封信正是这个形状:`jianf → dsh` 抄送 pi/opencode/zcode/homeagent,**pi 最先
回复(= 它读过了)** ⇒ 这封对 dsh 也变成 read ⇒ dsh 的 `read_inbox`(默认 unread)
返回空 ⇒ 它只能按提示词里的 mail_id 兜。

三个受害面:① Agent 的 `read_inbox` 拿不到信(换一个不兜的模型就变成"正文是空的");
② 人类的未读被抄送的 Agent 读掉;③ ★ 桥的补投判据 `pending_mails = CountUnread` 归零
⇒ SSE 漏过或进程重启时那封信**不再补投**(静默丢信)。

改动:
- 新表 `mail_reads(mail_id, reader_name, read_at)`,未读 = 这张表里没有该读者的行。
- 判据收敛到一处(repo 的 `unreadFor` / `readStateFor`),六处读写点全部改用它:
  单封已读、批量标已读、权限决策(只记**决策人**)、`ListInbox`(过滤 + 返回的
  status 都按读者算)、`CountUnread`、`CountUnreadInSession`、会话列表未读计数。
- 一次性回填补历史:`mails.status='read'` 记到**主收件人**名下(唯一可用的推断),
  用 `app_meta` 里的标记守住 —— 不能每次启动都跑,那会把"某抄送方读过"按主收件人
  写成已读,正是这次要修的错。实测:`done rows=207`。
- `mails.status` 保留为"有人读过 / 已归档"的冗余列,**不再是判据**。

★ 顺带挖出并修掉一个真 bug:`CountUnreadInSession` 用的是 PG 专有语法
(`cc_list @> $3::jsonb`),而线上是 SQLite ⇒ 那条 SQL **语法错误**
(`unrecognized token: "@"`),调用点又是 `unread, _ :=`(吞错)⇒
**会话列表的未读数一直是 0**。现已改用仓库既有的方言助手 `db.CCHas`。
实测:happy-pixel 会话现在 `unread_count=5`(修复前恒 0)。

判据:新增 `internal/repo/readstate_test.go`(5 条:按读者未读、会话内计数、
批量标已读、归档对所有人可见性、权限决策只记决策人)。
**扰动验证**:把 `unreadFor` 退回旧语义 → 4 条判据全红;恢复 → 绿。
全量 server 10 包全绿。文档同步:API.md 的「标记已读」段 + PLUGIN-CONTRACT 的 T-1.4。

线上复验:同一受控实验 —— gui-lab 读掉后,**jianf 的未读仍在且 status=unread** ✅
2026-09-13 14:25:44 +08:00
a696b2a141 fix(handler): 回信的 to_workspace 从会话 workspace 继承 —— 修「每封邮件多一条会话」
# 现象(生产实测)

在 DSH 界面上观察到的:每处理一封邮件就多出一条独立会话。

# 根因

`to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,而它取的是
**地址里的 path 位**。Agent 之间的回信、以及人在对话页点回复,地址里通常没有
path 位 —— 平台下发的 `reply_address` 就是这个形状(`FormatAddress(replyTo, "", alias)`)。

空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在一个
不同的空目录**里;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理一封邮件
就多出一条未分组会话」,而模型在那个空目录里什么项目文件也看不到。

实测取证:
  - 线上 5 个兜底目录 `~/.dsh/mail-sessions/mail-*` **全部是空的**(0 条目)
  - 全天 journalctl 里**没有任何**相关告警(代码用的是 `ctx.logger.warn`,
    而同一文件别处明确写着 DSH 的 logger 不进 journalctl)→ 完全静默
  - 走兜底的那条会话(8e982e96)里,`dsh → opencode` 那封 `to_workspace` 有值,
    而 `opencode → dsh` 的回信 **to_workspace 全为空** —— 而该会话自身的
    `sessions.workspace` 一直是有值的

# 修法

会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):回信本来就是
回给**那条会话**的,而那条会话知道自己属于哪个项目。规则抽成纯函数
`resolveToWorkspace(addrPath, sessionWorkspace, toIsHuman)`:

  1. 地址里写了 path → 照用(人的明确意图优先)
  2. 没写且收件方是**人** → 保持空(人没有工作目录;填了前端会拼出
     `gui-lab@/path.别名` 这种错地址,ToHuman 字段就是为此加的)
  3. 没写且收件方是 Agent → 用会话的 workspace

**改的是 `to`,不是只改建库那一行**:同一个值还进投递载荷(`to_workspace` /
`self_address`)。改一处另一处不改,会出现「API 读到的与插件推到的不是同一个
目录」——那正是本项目一直在治的静默不一致。

`notify/mail.go` 只加了一段注释说明 reply_address 的 path 位为何**刻意留空**
(它的语义是「**发件人**该在哪儿干活」),免得后人以为那是漏填。

# 测试

- `handler/toworkspace_test.go`:6 条规则用例 + 1 条**反向对照**
  (固定其他输入只翻转 toIsHuman,要求结果必须不同 —— 防止该参数被忽略后
  「给人也填 path」静默回归)
- `repo/session_workspace_test.go`:锁住**列名与真实 schema**。这个查询读不到时
  按设计返回空串,与「这条会话没有工作目录」无法区分 → 列名写错的功能表现是
  「看起来还在跑,只是工作目录永远继承不到」
2026-09-12 11:19:19 +08:00
f9d757b5e5 chore: directory migration - gateway→server, web→client/electron 2026-09-08 19:16:35 +08:00