diff --git a/plugins/pi-mail-bridge/lib/denial-reason.js b/plugins/pi-mail-bridge/lib/denial-reason.js new file mode 100644 index 0000000..2a8e368 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/denial-reason.js @@ -0,0 +1,40 @@ +/** + * 把「人类对这次权限请求的结论」渲染成模型能读懂的一段话。 + * + * # 为什么单独一个模块 + * + * 三条路径都要给模型同一句话:工具拦截(bash/write…)、无挂起 worker 时的通知投递、 + * 以及主动询问(kind=question)。而 2026-09-13 的线上缺陷正是这句话里**没有人类的备注**: + * + * - 用户原话:「被拒绝的 agent 好像看不到我给授权的备注」 + * - 现场证据(pi 会话转录):模型收到的工具结果只有 + * `用户拒绝了这次 bash 调用`;而人类那封决策邮件里写着 + * `拒绝\n\n备注: 我说了让你拉取仓库到program下你听不懂吗` + * - 后果:模型不知道要改什么,把同一条命令换个写法又问了一遍(实测连问 9 次)。 + * + * 备注在 `index.mjs → pool.mjs → worker.mjs` 三处被逐个丢掉,所以判据必须落在 + * **模型最终看到的那段文本**上(这个函数的返回值),而不是"某个字段有没有传"。 + */ + +/** 模型最终看到的那句"被拒了",附上人类说明与等人期间新到的邮件。 */ +export function renderDecisionReason({ toolName, decision, note = '', waiting = [] }) { + const base = decision === 'shutdown' + ? '用户未及决策(桥已关停)' + : `用户拒绝了这次 ${toolName} 调用`; + + const parts = [base]; + + const text = String(note ?? '').trim(); + if (text) parts.push(`用户的说明:${text}`); + + const fresh = Array.isArray(waiting) ? waiting.filter(Boolean) : []; + if (fresh.length) { + parts.push([ + `【等人审批期间新到的邮件 ${fresh.length} 封,尚未标记已读】`, + ...fresh, + '请先用 read_inbox 读这些邮件,再决定下一步 —— 里面可能是对当前做法的更正。', + ].join('\n')); + } + + return parts.join('\n\n'); +} diff --git a/plugins/pi-mail-bridge/lib/waiting-mails.js b/plugins/pi-mail-bridge/lib/waiting-mails.js new file mode 100644 index 0000000..69a6cb9 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/waiting-mails.js @@ -0,0 +1,48 @@ +import { renderMail } from './inbox-format.js'; + +/** + * 「等人点头期间新到的邮件」—— 权限恢复时要一并交给模型的那几封。 + * + * # 为什么需要它 + * + * 一个 worker 在等人类点「同意/拒绝」时是**停放**的(不占并发额度,见 pool.mjs), + * 而这条会话的新邮件会被排队等在它后面。于是人类在等待期间发来的更正(实测: + * 22:18:08 发出的「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」) + * 要等整个回合结束才被看到 —— 22:26:30,整整 8 分钟,期间模型一直在错误的目录上打转。 + * + * 而那一刻 worker 正好卡在等决策,本来就是"要重新给模型喂上下文"的时刻 —— + * 决策一到就把这些人发的新邮件一并附上,模型当场就能改道。 + * + * # 为什么是纯函数 + * + * 筛选规则容易写错(漏掉会话过滤就会把别人会话的信塞进来;忘了排除权限邮件就会 + * 把决策回执自己当成"新更正"),而这些错误都不会抛异常、只会让提示读起来不对 —— + * 必须有判据钉住。 + */ + +/** 权限请求/决策是控制面邮件,不是"新到的信"。 */ +const PERMISSION_TYPES = new Set(['permission_request', 'permission_decision']); + +/** + * @param {object|Array} inbox `/mail/inbox` 的回包(或它的 mails 数组) + * @param {object} opts + * @param {string} [opts.sessionID] 只取这条会话里的(空则不筛) + * @param {Set} [opts.seen] 已经交付过的 mail_id(不重复给) + * @param {number} [opts.limit] 最多几封(默认 3:再多会把工具结果撑爆) + * @param {number} [opts.bodyLimit] 每封正文截断长度 + * @returns {string[]} 已渲染好的条目,可直接拼进拒绝理由 + */ +export function selectWaitingMails(inbox, { sessionID = '', seen = new Set(), limit = 3, bodyLimit = 300 } = {}) { + const list = Array.isArray(inbox) ? inbox : (inbox?.mails ?? []); + const out = []; + for (const m of list) { + if (!m?.mail_id || seen.has(m.mail_id)) continue; + if (PERMISSION_TYPES.has(m.mail_type)) continue; + // 会话过滤只在两边都有值时生效:老回包没有 session_id 时不该把邮件全丢掉 + // (宁可多给一封,也不能让人类的更正消失)。 + if (sessionID && m.session_id && m.session_id !== sessionID) continue; + out.push(renderMail(m, bodyLimit, '')); + if (out.length >= limit) break; + } + return out; +} diff --git a/plugins/pi-mail-bridge/src/index.mjs b/plugins/pi-mail-bridge/src/index.mjs index 2ebf44d..aa681d2 100644 --- a/plugins/pi-mail-bridge/src/index.mjs +++ b/plugins/pi-mail-bridge/src/index.mjs @@ -46,6 +46,7 @@ import { BoundedSet, MAX_TRACKED_MAILS } from '../lib/bounded.js'; import { snapshotPiModels } from '../lib/model-scope.js'; import { snapshotPiSessions } from '../lib/session-snapshot.js'; import { selectCatchup } from '../lib/catchup.js'; +import { selectWaitingMails } from '../lib/waiting-mails.js'; // ─── 配置 ─── @@ -161,19 +162,58 @@ function releaseLock() { * - 那条邮件会话从没被处理过:连通知都无处可投,只能记一行日志。 */ function handlePermissionDecision(data) { - const relayKey = data.relay_key || ''; - if (relayKey && pool.routePermission(relayKey, String(data.decision || '拒绝'))) { - log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker`); + // 决策回执的内容马上随这次恢复交给 worker(备注 + 等人期间新到的邮件都在里面), + // 所以先把它记成"已交付":稍后那封同内容的邮件就不会被当成新任务再起一轮。 + const decisionMailID = data?.decision_mail_id || ''; + if (decisionMailID) deliveredMails.add(decisionMailID); + + // 拉"等人期间新到的邮件"要发一次 HTTP,而本函数跑在 SSE 读循环上(必须廉价) + // —— 所以整体转异步:先把事件收下,几毫秒后带着上下文去唤醒 worker。 + routeDecision(data).catch((e) => log(`决策投递失败: ${describeError(e)}`)); +} + +/** + * 把决策连同**人类说了什么**一起交给挂起的 worker。 + * + * 缺了 `note` 就是这次线上缺陷:人类写「我说了让你拉取仓库到program下你听不懂吗」, + * 模型只收到「用户拒绝了这次 bash 调用」,于是把同一条命令改个写法又问一遍(连问 9 次)。 + */ +async function routeDecision(data) { + const relayKey = data?.relay_key || ''; + const decision = String(data?.decision || '拒绝'); + const note = typeof data?.note === 'string' ? data.note : ''; + const sessionID = data?.session_id || ''; + const waiting = sessionID ? await collectWaitingMails(sessionID) : []; + + if (relayKey && pool.routePermission(relayKey, decision, note, waiting)) { + log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker` + + (note ? ',带备注' : '') + + (waiting.length ? `,并附上等人期间新到的 ${waiting.length} 封邮件` : '')); return; } - if (!data.session_id || !pool.hasSession(data.session_id)) { + if (!sessionID || !pool.hasSession(sessionID)) { // **不得凭空新开会话**(B-4.3) log(`权限决策 ${relayKey} 无对应会话,忽略`); return; } log(`权限 ${relayKey} 无挂起项,退化为通知投递`); - pool.submit('permission', data); + pool.submit('permission', { ...data, note }); +} + +/** + * 等人点头期间新到的邮件(人类很可能在这期间补一句更正)。 + * + * 拉取失败**不能拖垮决策投递**:备注还在,最坏情况是模型晚一步看到更正。 + */ +async function collectWaitingMails(sessionID) { + try { + const box = await client.get('/mail/inbox?status=unread&limit=20'); + return selectWaitingMails(box, { sessionID, seen: deliveredMails }); + } catch (e) { + log(`等人期间的邮件拉取失败(不影响决策投递): ${describeError(e)}`); + return []; + } } // ─── 心跳(B-2)─── @@ -372,6 +412,25 @@ function handleSSEEvent(type, data) { const id = data?.mail_id; if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重 deliveredMails.add(id); + // ★ 决策回执**不是新任务**(2026-09-13 线上缺陷)。 + // + // 它长得像普通邮件("Re: 权限请求 - 拒绝"),内容却已经随 SSE 的 + // permission_decision 直接交给了发起询问的 worker。以前它会**再**起一轮: + // 同一件事处理两次,还把人类真正的新邮件挤在这条会话的队列后面 —— + // 实测人类的更正 22:18:08 发出,直到 22:26:30 才被模型看到。 + // + // 走到这里说明决策事件还没到(事件先到就已经记进 deliveredMails 了)—— + // 那就按 B-4.3 当**通知**投进原会话,而不是凭空当新任务。 + if (data?.mail_type === 'permission_decision') { + if (!data?.session_id || !pool.hasSession(data.session_id)) { + log(`决策回执 ${id} 无对应会话,忽略(不凭空新开)`); + return; + } + log(`决策回执 ${id} 按通知投递(决策事件尚未到达)`); + pool.submit('permission', data); + return; + } + pool.submit('mail', data); } diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index 041b327..ed25af1 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -308,7 +308,7 @@ export function createWorkerPool({ * (桥重启、硬超时被杀、或者处理已经结束)—— 调用方据此走 B-4.2 的 * 降级路径(把决策当一封通知投进原会话)。 */ - function routePermission(relayKey, decision) { + function routePermission(relayKey, decision, note = '', freshMails = []) { const key = permissionRoutes.get(relayKey); if (!key) return false; const entry = running.get(key); @@ -323,7 +323,10 @@ export function createWorkerPool({ armTimeout(entry); log(`worker ${entry.child.pid} 收到决策,恢复占用并发额度(停放 ${parkedCount()}/${maxParked})`); } - entry.child.send({ type: 'permission_decision', relayKey, decision }); + // 备注与「等人期间新到的邮件」必须一起送到:以前只传 decision,于是人类写 + // 「我说了让你拉取仓库到program下你听不懂吗」,模型只看到「拒绝」, + // 转头把同一条命令又问了一遍(2026-09-13 实测连问 9 次)。 + entry.child.send({ type: 'permission_decision', relayKey, decision, note, freshMails }); return true; } diff --git a/plugins/pi-mail-bridge/src/turn.mjs b/plugins/pi-mail-bridge/src/turn.mjs index 2f9d25e..a253770 100644 --- a/plugins/pi-mail-bridge/src/turn.mjs +++ b/plugins/pi-mail-bridge/src/turn.mjs @@ -123,11 +123,16 @@ export function describeError(err) { */ export function buildMailPrompt({ agentName, data, kind, reused }) { if (kind === 'permission') { - return [ + const lines = [ `你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` + `(决策人:${data?.decided_by || '用户'})。`, - `请据此继续后续工作。`, - ].join('\n'); + ]; + // 人类的备注必须带上:这条路(无挂起 worker 时把决策当通知投进会话)与工具 + // 拦截那条是同一个信息,缺失后果一样 —— 模型不知道要改什么。 + const note = typeof data?.note === 'string' ? data.note.trim() : ''; + if (note) lines.push(`用户的说明:${note}`); + lines.push('请据此继续后续工作。'); + return lines.join('\n'); } // 发件方是人还是 Agent,以及这封是不是回信 —— 两个信号都来自服务端。 diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index cefd367..3c7078e 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -44,6 +44,7 @@ import { join } from 'node:path'; import { ModelRuntime } from '@earendil-works/pi-coding-agent'; import { GatewayClient } from './gateway.mjs'; +import { renderDecisionReason } from '../lib/denial-reason.js'; import { createMailTools } from './tools.mjs'; import { openSession, runTurn } from './session-pool.mjs'; import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError, renderResumeFailure } from './turn.mjs'; @@ -89,6 +90,10 @@ const grants = new Set(); let job = null; let client = null; + +// 每次权限询问「人类说了什么」(备注 + 等人期间新到的邮件),按 relayKey 暂存。 +// 见 lib/denial-reason.js 的说明。 +const decidedExtra = new Map(); let modelRuntime = null; let piSessionId = ''; let mailContext = { replyTo: '', subject: '', mailID: '', permissionMode: 'workspace' }; @@ -237,9 +242,16 @@ function permissionExtension() { log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`); return; } + const extra = decidedExtra.get(relayKey) || {}; + decidedExtra.delete(relayKey); return { block: true, - reason: `用户${decision === 'shutdown' ? '未及决策(桥已关停)' : `拒绝了这次 ${event.toolName} 调用`}`, + reason: renderDecisionReason({ + toolName: event.toolName, + decision, + note: extra.note, + waiting: extra.waiting, + }), }; }); }; @@ -654,7 +666,13 @@ process.on('message', (msg) => { const resolve = pending.get(msg.relayKey); if (!resolve) return; pending.delete(msg.relayKey); - resolve(String(msg.decision || '拒绝')); + + // 备注与「等人期间新到的邮件」按 relayKey 暂存,等那次工具调用回来渲染 + // 拒绝理由时取走。不存全局变量:一个回合里可能有多次询问先后落地。 + decidedExtra.set(msg.relayKey, { + note: typeof msg.note === 'string' ? msg.note : '', + waiting: Array.isArray(msg.freshMails) ? msg.freshMails : [], + }); return; } if (msg?.type === 'shutdown') { diff --git a/plugins/pi-mail-bridge/test/permission-note.test.mjs b/plugins/pi-mail-bridge/test/permission-note.test.mjs new file mode 100644 index 0000000..a57b27f --- /dev/null +++ b/plugins/pi-mail-bridge/test/permission-note.test.mjs @@ -0,0 +1,143 @@ +/** + * 「人类对权限请求的说明必须到达模型」—— 2026-09-13 线上缺陷的判据。 + * + * # 缺陷现场 + * + * 用户在界面上拒绝一条 bash 请求并写下备注: + * + * 拒绝 + * + * 备注: 我说了让你拉取仓库到program下你听不懂吗 + * + * 而模型那一侧收到的工具结果只有: + * + * 用户拒绝了这次 bash 调用 + * + * pi 会话转录可查(`~/.pi/agent/sessions/--home-program--/.jsonl`)。模型于是 + * 不知道要改什么,把同一条命令换个写法又问一遍 —— 现场连问 9 次。 + * + * # 为什么既验纯函数又验接线 + * + * 备注在 `index.mjs → pool.mjs → worker.mjs` 三处被逐个丢掉:纯函数测试对它无能为力 + * (函数是对的,只是没人把参数传下去),而只验接线又验不出"渲染出来的字对不对"。 + * 所以两层都要:纯函数验文本,接线验参数真的被透传(与 permission-forward-wiring 同一取舍)。 + */ + +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'; + +import { renderDecisionReason } from '../lib/denial-reason.js'; +import { selectWaitingMails } from '../lib/waiting-mails.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = join(HERE, '..', 'src'); +const read = (f) => readFileSync(join(SRC, f), 'utf8'); + +const NOTE = '我说了让你拉取仓库到program下你听不懂吗'; + +test('备注必须出现在模型看到的拒绝理由里', () => { + const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: NOTE }); + assert.match(out, /用户拒绝了这次 bash 调用/); + assert.ok(out.includes(NOTE), `理由里必须原样带上人类写的备注,实际:${out}`); +}); + +test('反向对照:没写备注时不该凭空造出"用户的说明"', () => { + const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: '' }); + assert.ok(!out.includes('用户的说明'), `无备注却出现了说明段:${out}`); + assert.match(out, /^用户拒绝了这次 bash 调用$/); +}); + +test('等人期间新到的邮件要附进理由,并指向 read_inbox', () => { + const waiting = ['[unread] jianf: 不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!']; + const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: NOTE, waiting }); + assert.ok(out.includes('是让你拉取到program仓库'), '更正内容必须出现在理由里'); + assert.match(out, /read_inbox/, '要点明让模型去读全文(理由里只有摘要)'); + assert.match(out, /1 封/, '要说明有几封,模型才知道要不要再拉'); +}); + +test('关停路径的文案不回归(未及决策 ≠ 拒绝)', () => { + assert.match(renderDecisionReason({ toolName: 'bash', decision: 'shutdown' }), + /未及决策(桥已关停)/); +}); + +// ─── selectWaitingMails ─── + +const mk = (over = {}) => ({ + mail_id: 'm-' + Math.random().toString(36).slice(2, 8), + session_id: 's1', mail_type: 'normal', from_name: 'jianf', + subject: 'Re: 权限请求 - 同意', body: '不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!', + ...over, +}); + +test('只挑这条会话、非权限类、且没交付过的邮件', () => { + const seen = new Set(['m-seen']); + const box = { mails: [ + mk({ mail_id: 'm-seen' }), + mk({ mail_id: 'm-other-session', session_id: 's2' }), + mk({ mail_id: 'm-req', mail_type: 'permission_request' }), + mk({ mail_id: 'm-dec', mail_type: 'permission_decision' }), + mk({ mail_id: 'm-keep' }), + ] }; + const out = selectWaitingMails(box, { sessionID: 's1', seen }); + assert.equal(out.length, 1, `只该有 1 封,实际:${JSON.stringify(out)}`); + assert.ok(out[0].includes('是让你拉取到program仓库')); +}); + +test('★ 反向对照:旧的权限回执不能被当成"人类的新更正"', () => { + const box = { mails: [mk({ mail_type: 'permission_decision' })] }; + assert.deepEqual(selectWaitingMails(box, { sessionID: 's1' }), []); +}); + +test('上限生效(再多也不能把工具结果撑爆)', () => { + const box = { mails: Array.from({ length: 9 }, () => mk()) }; + assert.equal(selectWaitingMails(box, { sessionID: 's1', limit: 3 }).length, 3); +}); + +test('回包没有 session_id 时不能把邮件全丢掉(宁可多给也不能漏更正)', () => { + const box = { mails: [mk({ session_id: undefined })] }; + assert.equal(selectWaitingMails(box, { sessionID: 's1' }).length, 1); +}); + +test('空回包/字段缺失不抛异常', () => { + assert.deepEqual(selectWaitingMails(undefined, {}), []); + assert.deepEqual(selectWaitingMails({ mails: [null, {}, mk({ mail_id: undefined })] }, {}), []); +}); + +// ─── 接线:参数必须真的被传下去 ─── + +/** 找「决策从桥到模型」这条链上必须存在的形状。 */ +export const WIRING = [ + { file: 'pool.mjs', what: 'routePermission 接住备注与等人期间的邮件', + must: /child\.send\(\{ type: 'permission_decision'[^}]*\bfreshMails\b/ }, + { file: 'pool.mjs', what: 'routePermission 签名带 note', + must: /function routePermission\(relayKey, decision, note/ }, + { file: 'worker.mjs', what: 'worker 收下 msg.note(并暂存到 decidedExtra)', + must: /decidedExtra\.set\(msg\.relayKey/ }, + { file: 'worker.mjs', what: '拒绝理由由 renderDecisionReason 渲染(而不是写死的字面量)', + must: /reason: renderDecisionReason\(/ }, + { file: 'index.mjs', what: '决策事件把 note 传给 pool', + must: /routePermission\(relayKey, decision, note, waiting\)/ }, + { file: 'index.mjs', what: '决策回执记成已交付(不再当新任务)', + must: /deliveredMails\.add\(decisionMailID\)/ }, + { file: 'index.mjs', what: 'new_mail 分支认得决策回执', + must: /mail_type === 'permission_decision'/ }, + { file: 'turn.mjs', what: '通知投递路径也带备注', + must: /lines\.push\(`用户的说明:\$\{note\}`\)/ }, +]; + +test('接线齐全(缺一处备注就断在那一环)', () => { + for (const w of WIRING) { + const src = read(w.file); + assert.ok(w.must.test(src), `${w.file}: ${w.what}`); + } +}); + +test('★ 判据自检:拿一段没有该形状的源码喂进来必须判红', () => { + const fake = "entry.child.send({ type: 'permission_decision', relayKey, decision });"; + const one = WIRING[0]; + assert.equal(one.must.test(fake), false, '接线断言对"只传 decision"的旧写法必须判红'); + assert.equal(one.must.test(read(one.file)), true, '对当前源码必须判绿'); +}); diff --git a/server/internal/repo/readstate_test.go b/server/internal/repo/readstate_test.go index 498a541..c556027 100644 --- a/server/internal/repo/readstate_test.go +++ b/server/internal/repo/readstate_test.go @@ -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) + } +} diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index 259fc05..5a01ee4 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -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