修 platform_session_id 无差别下发导致抄送方邮件静默消失 + homeagent 补投漏去重
## platform_session_id 只发给归属方(Gateway) `notify.Recipients` 原来对所有参与方推同一个 `platform_session_id`, 而那是**会话级**的一个值。生产实测:会话 16845133 接管了 pi 的平台会话 `01a05a5e-…`,那封邮件抄送了 dsh@/home/program/agentmail.new。DSH 收到 同一个 id,在 ~/.dsh/sessions/ 里查不到(那是 /root/.pi/agent/sessions/ 下的文件),于是走进「平台侧会话已删」那道防线抛错。 那道防线本身是对的(N-8:不能退回新建,否则人在界面上看不到这封邮件带来 的对话),它拦下的却是「别人的会话」。异常被 ctx.logger.error 吞掉,而 DSH 的 logger 不进 journalctl —— 邮件静默消失,日志里一个字都没有。 - 新增 `repo.PlatformSessionFor` 一并返回归属 Agent:以镜像 `agent_platform_sessions.agent_name` 为准,镜像整表替换后退回 `sessions.from_agent`(AdoptPlatformSession 写在那里) - `PlatformIDOf` 变薄封装,保留原签名 - `notify.Recipients` 加 `platformFor(forName)`:归属方以外一律空串; 归属抽不到时(owner 空)也不下发 —— 宁可退回当普通会话处理, 也不让一个抽不到归属的 id 把邮件弄丢 - 归属与收件角色无关:归属方在抄送位上同样拿到 ## homeagent catchUp 漏 deliveredMails 去重 `go p.catchUp(…)` 与 `go p.sseLoop()` 是两个并发 goroutine,重启时窗口 重叠:SSE 推一次 + 补投拉一次 = 同一封邮件注入两遍。homeagent 的回信正文 印证了这一点(「之前的对话时序中已经收到并确认过多次了」)。另三个插件的 catchUp 都有这层双查,只有这里漏了。 去重放在循环内逐封查而不是拉完一批再筛:InjectInputSync 一封要跑几十秒, 那期间 SSE 完全可能已经投过后面那几封。 ## DSH 接管失败改用 console.error DSH 的 ctx.logger 不进 journalctl,投递失败是「发件人等不到回信」的唯一 线索。接管失败点与 SSE 分发的 catch 都改走 console.error,并带上 mail_id 与发件人。 ## 前端 ccAddress 移除(收尾上一轮未提交的改动) cc_list 里的 `.new` 是**原始意图**,不该被替换成主收件人的别名:每个抄送 方的 `.new` 是独立的 —— pi@/x.new 给 pi 开一条、dsh@/x.new 给 dsh 开另一 条,各有自己的别名。数据库存的就是原文。删掉 ccAddress,MailView / ThreadView 直接显示 c.raw。 ## 测试 - `internal/notify/notify_test.go` +3 例:挂真实 SSE 客户端读帧,验 归属方拿到 / 抄送方为空 / 归属方在抄送位也拿到 / 普通会话全空。 负向对照跑过:platformFor 无条件返回时两条用例失败 - `internal/repo/platform_owner_test.go` +3 例:镜像取归属、普通会话、 镜像被清后退回 from_agent - 修好 web/test/components/replyTarget.test.tsx(上一轮遗留的语法损坏), 三条 .new 用例改成断言原样保留 - gateway 7 包全绿;web 176 例 + 主题 26;dsh 219 / pi 250 / opencode 201 ## 生产验证 - 抄送验证:jianf → pi(接管会话)cc dsh。DSH 正常建会话并回信「收到」, pi 走接管续谈 —— 两封回信都落在同一条线索上(此前 DSH 那封不存在) - homeagent 去重:连发两轮,其中一轮在邮件未处理完时重启 homeagent 造出 SSE/catchUp 并发窗口,两轮都只产生一封 Re: - homeagent SSE:换新 plugin.bin 后连续 89 分钟零断连(此前 2 小时 102 次 deadline exceeded 自激振荡)
This commit is contained in:
@ -10,8 +10,7 @@ import {
|
||||
mailReplyTarget,
|
||||
mailCounterpart,
|
||||
replyAllCC,
|
||||
participantAddress,
|
||||
ccAddress
|
||||
participantAddress
|
||||
} from '../lib/replyTarget';
|
||||
import * as api from '../api/client';
|
||||
import type { Mail } from '../types';
|
||||
@ -436,10 +435,8 @@ function Header({
|
||||
<Row label="发件">{from}</Row>
|
||||
<Row label="收件">{to}</Row>
|
||||
{mail.cc_list?.length > 0 && (
|
||||
// 抄送走 ccAddress:把一次性的 `.new` 换成真实会话别名。
|
||||
// 留着 `.new` 会让人以为再发一次还能投进同一条会话,实际会开新的。
|
||||
<Row label="抄送">
|
||||
{mail.cc_list.map(a => ccAddress(a, mail.session_alias)).join('、')}
|
||||
{mail.cc_list.map(a => a.raw || `${a.name}@${a.path || ''}`).join('、')}
|
||||
</Row>
|
||||
)}
|
||||
<Row label="时间">{time}</Row>
|
||||
@ -493,7 +490,7 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<span
|
||||
className="text-[10px] text-gray-400"
|
||||
title={mail.cc_list.map(c => ccAddress(c, mail.session_alias)).join(', ')}
|
||||
title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')}
|
||||
>
|
||||
抄送 {mail.cc_list.length}
|
||||
</span>
|
||||
@ -661,7 +658,7 @@ function ReplyBar({
|
||||
const replyAll = () => {
|
||||
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
|
||||
// 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。
|
||||
setCc(replyAllCC(replyTo, me, peer.name, replyTo.session_alias).join(', '));
|
||||
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
|
||||
setCcOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { participantAddress } from '../lib/replyTarget';
|
||||
import type { ThreadNode } from '../types';
|
||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
import { participantAddress, ccAddress } from '../lib/replyTarget';
|
||||
|
||||
import BackButton from './BackButton';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
@ -281,7 +282,7 @@ function Node({
|
||||
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
|
||||
{ccCount > 0 && (
|
||||
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
|
||||
抄送 {node.cc_list.map(c => ccAddress(c, node.session_alias)).join('、')}
|
||||
抄送 {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}
|
||||
</p>
|
||||
)}
|
||||
{node.body_preview && (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Address, Mail, Session } from '../types';
|
||||
import type { Mail, Session } from '../types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
@ -177,14 +177,13 @@ export function mailReplyTarget(mail: Mail, me: string): string {
|
||||
export function replyAllCC(
|
||||
mail: Mail,
|
||||
me: string,
|
||||
primaryName: string,
|
||||
currentAlias?: string | null
|
||||
primaryName: string
|
||||
): string[] {
|
||||
const raw = [
|
||||
formatAddress(mail.from_name, mail.from_workspace || ''),
|
||||
formatAddress(mail.to_name, mail.to_workspace || ''),
|
||||
// cc_list 走 ccAddress:它把一次性的 `.new` 换成真实会话别名
|
||||
...(mail.cc_list ?? []).map(c => ccAddress(c, currentAlias))
|
||||
// cc_list 使用 raw 字段(用户输入的原文,保留 .new 等原始意图)
|
||||
...(mail.cc_list ?? []).map(c => c.raw || formatAddress(c.name, c.path || '', c.session || null))
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
@ -202,23 +201,4 @@ export function replyAllCC(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个抄送地址的展示/投递形式,把 `.new` 换成真实会话别名。
|
||||
*
|
||||
* `.new` 只在**发信那一刻**有意义:它建完会话就用完了。存档后继续显示
|
||||
* `pi@/x.new` 会让人以为再发一次还能投进同一条会话,实际会开出第三条 ——
|
||||
* 与插件侧 SSE 事件把 `.new` 换成真别名(`reply_address`)同一道理。
|
||||
*
|
||||
* 不知道真别名时省略会话位而不是保留 `.new`:`name@path` 至少指向默认会话,
|
||||
* 而 `.new` 一定会建新的。
|
||||
*/
|
||||
export function ccAddress(cc: Address, currentAlias?: string | null): string {
|
||||
if (!cc?.name) return cc?.raw || '';
|
||||
// 人(无 path)只显示名字 —— 与 participantAddress 同一判据。
|
||||
// 抄送给人时地址里出现会话位是把 Agent 的维度套在人身上。
|
||||
if (!(cc.path || '').trim()) return cc.name;
|
||||
if ((cc.session || '').trim() === 'new') {
|
||||
return formatAddress(cc.name, cc.path || '', currentAlias || null);
|
||||
}
|
||||
return cc.raw || formatAddress(cc.name, cc.path || '', cc.session || null);
|
||||
}
|
||||
|
||||
|
||||
@ -6,10 +6,9 @@ import {
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
replyAllCC,
|
||||
participantAddress,
|
||||
ccAddress
|
||||
participantAddress
|
||||
} from '../../src/lib/replyTarget';
|
||||
import type { Address, Mail, Session } from '../../src/types';
|
||||
import type { Mail, Session } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
@ -333,44 +332,14 @@ describe('participantAddress', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ccAddress', () => {
|
||||
const cc = (over: Partial<Address> = {}): Address => ({
|
||||
name: 'pi',
|
||||
path: '/home/program/agentmail',
|
||||
session: '',
|
||||
raw: 'pi@/home/program/agentmail',
|
||||
...over
|
||||
});
|
||||
|
||||
it('.new 换成当前会话别名', () => {
|
||||
const a = cc({ session: 'new', raw: 'pi@/home/program/agentmail.new' });
|
||||
expect(ccAddress(a, '我的任务')).toBe('pi@/home/program/agentmail.我的任务');
|
||||
});
|
||||
|
||||
it('不知道真别名时省略会话位,不保留 .new', () => {
|
||||
const a = cc({ session: 'new', raw: 'pi@/home/program/agentmail.new' });
|
||||
// `name@path` 至少指向默认会话;`.new` 一定会建新的
|
||||
expect(ccAddress(a, null)).toBe('pi@/home/program/agentmail');
|
||||
expect(ccAddress(a)).toBe('pi@/home/program/agentmail');
|
||||
});
|
||||
|
||||
it('已有具体别名时原样保留(那是用户写下的原文)', () => {
|
||||
const a = cc({ session: 'other-task', raw: 'pi@/home.other-task' });
|
||||
expect(ccAddress(a, '我的任务')).toBe('pi@/home.other-task');
|
||||
});
|
||||
|
||||
it('无 raw 时按结构化字段重拼', () => {
|
||||
const a = cc({ session: 'x', raw: '' });
|
||||
expect(ccAddress(a, null)).toBe('pi@/home/program/agentmail.x');
|
||||
});
|
||||
|
||||
it('name 缺失时退回 raw 而不是抛错', () => {
|
||||
expect(ccAddress({ name: '', path: '', session: '', raw: '原文' } as Address)).toBe('原文');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC 的 .new 处理', () => {
|
||||
it('抄送里的 .new 被换成当前会话别名', () => {
|
||||
describe('replyAllCC 保留 cc_list 的原始意图', () => {
|
||||
// `.new` 是**原始意图**,不该被替换成主收件人的别名。
|
||||
//
|
||||
// 每个抄送方的 `.new` 是独立的:`pi@/x.new` 给 pi 开一条会话、
|
||||
// `dsh@/x.new` 给 dsh 开另一条,各有自己的别名。把它们统一换成主收件人
|
||||
// 那条会话的别名,等于把三条不同的线索说成同一条 —— 而数据库里 cc_list
|
||||
// 存的就是原文,显示原文没有任何问题。
|
||||
it('抄送里的 .new 原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
@ -380,19 +349,30 @@ describe('replyAllCC 的 .new 处理', () => {
|
||||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||||
}]
|
||||
});
|
||||
// 自己是 jianf、主收件人是 pi,剩下 dsh —— 且 .new 已换成真别名
|
||||
expect(replyAllCC(m, 'jianf', 'pi', '当前任务')).toEqual(['dsh@/opt.当前任务']);
|
||||
// 自己是 jianf、主收件人是 pi,剩下 dsh —— `.new` 是原文,不动
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.new']);
|
||||
});
|
||||
|
||||
it('不传别名时抄送退到默认会话而不是 .new', () => {
|
||||
it('已有具体别名的抄送也原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new' }]
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'other-task', raw: 'dsh@/opt.other-task' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.other-task']);
|
||||
});
|
||||
|
||||
it('抄送无 raw 时按结构化字段重拼(含会话位)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'x', raw: '' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.x']);
|
||||
});
|
||||
|
||||
it('人类发件人(无 workspace)在抄送里是裸名字,不带会话位', () => {
|
||||
@ -406,20 +386,6 @@ describe('replyAllCC 的 .new 处理', () => {
|
||||
// replyAllCC 里的 from/to 走 formatAddress 且**不传 session** ——
|
||||
// 抄送清单是「还要发给谁」,会话由主收件人的地址决定,
|
||||
// 每个抄送方都带一遍会话位是冗余的(且回复时后端按 reply_to 定位会话)
|
||||
expect(replyAllCC(m, 'dsh', 'pi', '某个很长的会话别名')).toEqual(['jianf']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ccAddress 对人与 Agent 的分流', () => {
|
||||
it('抄送给人时只显示名字(不带会话位)', () => {
|
||||
const human = { name: 'jianf', path: '', session: '', raw: 'jianf' } as Address;
|
||||
expect(ccAddress(human, '某会话')).toBe('jianf');
|
||||
});
|
||||
|
||||
it('抄送给 Agent 时带完整三段', () => {
|
||||
const agent = {
|
||||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||||
} as Address;
|
||||
expect(ccAddress(agent, '当前任务')).toBe('dsh@/opt.当前任务');
|
||||
expect(replyAllCC(m, 'dsh', 'pi')).toEqual(['jianf']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user