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` 核对。
This commit is contained in:
2026-09-14 09:19:25 +08:00
parent be13ae5959
commit 552fbc731e
6 changed files with 289 additions and 16 deletions

View File

@ -61,7 +61,17 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
* opencode / dsh / homeagent 三个平台都没写这一行,只有这里写了 —— 它不是
* 「更严格更好」,而是与 pi 的参数传递机制直接冲突。
*/
export function createMailTools({ client, log, agentName = '', onReconnect }) {
export function createMailTools({ client, log, agentName = '', onReconnect, getMailSessionId = () => ''}) {
// 收件箱列表的会话收窄参数。
//
// ★ 缺了它,这条列表会把**别的会话**的来信一起列出来并按契约标成已读 ——
// 别的会话的 worker 之后按 ?status=unread 补投时就再也看不到那封信。
// 用户原话:「不同 session 的 agent 都可以看到全部邮件」。
const inboxScope = () => {
const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : '';
return sid ? `&session_id=${encodeURIComponent(sid)}` : '';
};
const sendMail = {
name: 'send_mail',
label: 'SendMail',
@ -142,7 +152,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) {
async execute(_id, params) {
const status = params.status || DEFAULT_INBOX_STATUS;
const { mails } = await client.get(
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`,
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}${inboxScope()}`,
);
// 渲染与已读策略走共用模块:与另两个平台必须一致,

View File

@ -96,7 +96,10 @@ let client = null;
const decidedExtra = new Map();
let modelRuntime = null;
let piSessionId = '';
let mailContext = { replyTo: '', subject: '', mailID: '', permissionMode: 'workspace' };
// sessionID = **AgentMail 的邮件会话 id**(不是 pi 的 session id
// read_inbox 要靠它把自己那条会话的邮件与别的会话区分开 —— 少了它A 会话的
// worker 会把 B 会话的未读一起列出来并标掉2026-09-14 用户报的缺陷)。
let mailContext = { replyTo: '', subject: '', mailID: '', sessionID: '', permissionMode: 'workspace' };
let lastSyncedName = '';
let relayedKey = '';
let finished = false;
@ -478,6 +481,8 @@ async function run() {
// connect_to_server 在 worker 里换了坐标要让主进程知道worker 马上就退了,
// 改在自己身上等于没改。主进程收到后重建 SSE 并写进后续 worker 的 job。
const mailTools = createMailTools({
// read_inbox 用它在服务端把列表收窄到自己这条会话。
getMailSessionId: () => mailContext.sessionID,
client, log, agentName: job.config.agentName,
onReconnect: () => send({
type: 'reconfigure', url: client.baseURL, agentKey: client.agentKey,
@ -653,6 +658,7 @@ process.on('message', (msg) => {
replyTo: msg.data?.from_name || '',
subject: msg.data?.subject || '',
mailID: msg.data?.mail_id || '',
sessionID: msg.data?.session_id || '',
permissionMode: msg.data?.permission_mode || 'workspace',
};
lastSyncedName = msg.lastSyncedName || '';

View File

@ -0,0 +1,35 @@
/**
* read_inbox 必须把列表收窄到**自己那条会话**2026-09-14 用户报的缺陷)。
*
* 「不同 session 的 agent 都可以看到全部邮件」—— 列表按 Agent 列,还按契约把列出的
* 都标已读 ⇒ A 会话的 worker 会标掉 B 会话的未读,而 B 之后按 `?status=unread`
* 补投时就再也看不到那封信(静默丢信)。现场实例:另一条会话的来信被我在读自己
* 收件箱的时刻标成已读mail_reads 里那行的 reader=pi、时间就是那次读取
*
* 这里只验**接线**(工具把 session_id 传出去了、worker 把邮件会话 id 递进来了);
* 服务端语义(读 A 不动 B由 server/internal/repo/session_scope_test.go 负责。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const tools = readFileSync(join(HERE, '..', 'src', 'tools.mjs'), 'utf8');
const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8');
test('read_inbox 的 URL 带上会话收窄', () => {
assert.match(tools, /mail\/inbox\?status=[^`]*\$\{inboxScope\(\)\}/, 'URL 必须拼上 inboxScope()');
assert.match(tools, /session_id=\$\{encodeURIComponent\(sid\)\}/, 'inboxScope 要真的带上 session_id');
});
test('worker 把邮件会话 id 递给工具', () => {
assert.match(worker, /getMailSessionId: \(\) => mailContext\.sessionID/, '要传闭包而不是快照');
assert.match(worker, /sessionID: msg\.data\?\.session_id/, '会话 id 来自 SSE 事件');
});
test('★ 判据自检:拿没带会话收窄的旧写法喂进来必须判红', () => {
const old = '`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`';
assert.equal(/mail\/inbox\?status=[^`]*\$\{inboxScope\(\)\}/.test(old), false);
});