DSH 续谈分支不刷回信上下文 → 第二封的回信挂在第一封上

## 症状

全流程回归时发现:同一条 dsh 会话的第二封邮件,回信主题写的是**第一封**的主题,
`parent_mail_id` 也指向第一封。实测(旧版负向对照):

  jianf  负向对照 第一封
  dsh    Re: 负向对照 第一封   parent=48c4fedc  ← 对
  jianf  负向对照 第二封
  dsh    Re: 负向对照 第一封   parent=48c4fedc  ← 错,应为「第二封」

模型答的内容是对的(收到甲 / 收到乙),坏的是回信的主题与线索归属 ——
在收件箱里看起来像「同一封信被回了两遍」,而第二封的回复无处可寻。

## 根因

`mailContexts` 只在两处写入:`bindAdopted`(接管时)与新开会话分支。
`deliverMail` 的**续谈分支**(`existing` 且 agent 还活着)不写 —— 于是自动转发
用的还是第一封的 subject / mailID。

pi 与 opencode 都没有这个问题:pi 的 worker 一封一进程,每次重建 mailContext;
opencode 在 `deliverMail` 开头统一刷,注释写的就是「一个会话里可能来过多封信,
只保留最近那封」。DSH 漏了这一处,语义与另两个平台不一致。

## 修法

续谈分支进入 `locked()` 后先刷 `mailContexts`(`kind === 'mail'` 才刷 ——
权限通知不是新来信,不该改回信目标)。

## 顺带:pi 主进程删掉不会被调用的 createMailTools

工具是给模型调的,而重构后主进程没有会话。`connect_to_server` 换坐标的闭环在
pool 的 `onReconfigure` 里(工具跑在 worker,worker 回报给主进程)。
schema 约束由 `test/tool-schema.test.mjs` 直接验 `createMailTools`,
不需要在主进程建一份没人用的副本。

## 验证

- 旧版负向对照:确认第二封的回信 parent 指向第一封(复现)
- 修复后:`Re: 修复确认 甲` parent=d879f429 / `Re: 修复确认 乙` parent=e8f216a2,
  各自归位
- 四平台同发一封(pi 接管会话 + dsh/opencode/homeagent 抄送):四封回信全部到位
- dsh 219 / pi 269 / tsc 0
This commit is contained in:
2026-09-04 21:00:11 +08:00
parent 8e501f041e
commit c941fa0f87
2 changed files with 22 additions and 13 deletions

View File

@ -667,6 +667,23 @@ export function apply(ctx: any, config: PluginConfig): void {
const live = ctx.agents.get(existing.dshSessionId);
if (live) {
return locked(existing.dshSessionId, async () => {
// 回信上下文必须刷成**这一封**。
//
// 续谈分支原来不刷mailContexts 只在 bindAdopted 与新开会话时写一次,
// 于是同一条会话的第二封邮件跑完后,自动转发用的还是**第一封**的
// subject / mailID —— 回信主题与 parent_mail_id 都指向上一封。
// 实测撞出过:回「全流程回归」那封的信,主题写的是上一封
// 「platformID 归属验证」parent 也挂在那封上。
//
// 与 pi 桥的语义对齐:那边每封邮件都重建 mailContextworker 一封一进程),
// 注释写的就是「一个会话里可能来过多封信,只留最近那封」。
if (kind === 'mail' && mailSessionID) {
mailContexts.set(mailSessionID, {
replyTo: data.from_name || '',
subject: data.subject || '',
mailID: data.mail_id || '',
});
}
const promptText = kind === 'permission'
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
: [

View File

@ -39,7 +39,6 @@ import { join } from 'node:path';
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs';
import { createMailTools } from './tools.mjs';
import { createWorkerPool } from './pool.mjs';
import { describeError } from './turn.mjs';
import { snapshotPiModels } from '../lib/model-scope.js';
@ -282,19 +281,12 @@ async function main() {
workerMaxMs: WORKER_MAX_MS,
});
// 主进程仍要一套邮件工具:它自己不跑模型,但 connect_to_server 的
// onReconnect 语义要在这里闭环worker 侧那套只负责回报给主进程)。
// 主进程不跑模型,因此**不建**邮件工具:工具是给模型调的,而这里没有会话。
// (工具 schema 的约束由 test/tool-schema.test.mjs 直接验证 createMailTools
// 不需要在这里建一份没人用的副本。)
//
// 这些工具不会被任何模型调用 —— 主进程没有会话。留着是因为
// createMailTools 同时承担「校验工具 schema」的职责test/tool-schema.test.mjs
// 而工具总数是契约里核对过的数字。
createMailTools({
client, log, agentName: AGENT_NAME,
onReconnect: () => {
client.stopSSE();
client.startSSE(handleSSEEvent, log);
},
});
// connect_to_server 换坐标的闭环在 pool 的 onReconfigure 里 —— 那个工具
// 跑在 worker 里worker 把新坐标回报给主进程,主进程据此重建 SSE。
try {
await client.register(); // B-1.2