feat(adopt): 邮件可投进平台上已存在的会话(TUI 与邮箱同一入口)
人在平台界面(pi TUI / opencode / DSH GUI)里开的会话,此前无法被邮件投进去。 补全早就把它们列为候选(agent_platform_sessions 镜像,插件心跳上报), 但投递侧的 FindNamedSessionFor 只查 sessions 表 —— 选中后只能得到 404。 候选列表在承诺一件做不到的事。 TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。 ## Gateway sessions 表加 platform_id 列 + 部分索引。resolveTarget 的 SessionNamed 分支 本侧查不到时再查镜像,命中则「接管」:本侧建一条会话并绑定 platform_id, 之后每次投递都在 SSE 事件里带 platform_session_id。 - FindPlatformSession(agent, slug, workspace) 查镜像 - FindSessionByPlatformID 防重复接管(一条平台会话只能被接管一次, 否则同一条对话在邮箱里裂成多条互不相干的线索) - AdoptPlatformSession 建会话 + 绑定 + 别名复用平台 slug(撞名自动加后缀) - PlatformIDOf 供 notifyRecipients 读 三处语义决定: - workspace 以平台会话为准(它的 cwd 创建时就定了)。地址 path 位不同则不命中, 否则邮件会投进另一个项目的会话 - 主题优先用平台侧标题(它代表整条对话在谈什么,也是补全里显示的) - 接管计入 AllowNewSession 速率限制 —— 镜像里可能有几百条 slug, 不计的话它是绕过限流的后门 ## 插件 字段解析与失败话术抽成共用模块 lib/adopt.js(三方逐字节相同 + 进同源校验): 字段名各写一遍时少个下划线就静默退化成「每封邮件新开一条」,而那个错误不抛异常。 - opencode:session.get 确认存在 → 照常 promptAsync(服务端持有会话,单一写者) - DSH:复用 startAgent 的 resume 分支,会话 id 换成平台自己那个; 界面上正开着时直接 followup(两个 handle 会各自写日志,replay 过不去) - pi:SessionManager.open(file) → 跑一轮 → dispose,不放进长期缓存 pi 必须短暂持有:SDK 无任何锁机制(flock/lockfile 命中 0),活着的 SessionManager 不 watch 文件 —— 外部追加的行看不见,算出的 parentId 指向 对方不知道的 entry,会话树分叉。写入是纯 append 所以文件不会坏。 配套三处:isStreaming 时不释放(否则杀掉排队中的下一封)、兜底计时器 (轮次超时 ×2,unref)、接管会话跳过命名同步。 最后一条是实测撞出来的:别名撞名时 Gateway 加后缀,而定稿别名又回写进 pi 会话文件 → 下次心跳上报的 slug 变成带后缀那个,人从补全里选的名字凭空消失。 opencode/DSH 无此环(它们的 slug 只读不写)。 接管后必须加入 mailDriven 集合,否则邮件投进去了却永远没有回音。 ## 迁移顺序 idx_sessions_platform 不能写在 init_sqlite.sql 里:那个脚本在 addMissingColumns 之前执行,而已部署的库里 sessions 表已存在 (CREATE TABLE IF NOT EXISTS 不补列)→ 索引建在不存在的列上, 整个迁移中断、服务起不来(生产实测)。依赖补出来的列的索引一律放 migrate.go 的 sqliteAddIndexes。PG 侧用 ALTER TABLE ADD COLUMN IF NOT EXISTS。 ## 生产验证 - pi × 2(agent-only-chain / mail-probe-alias)、opencode(glowing-moon)、 dsh(查看工程与插件适配指南)四条链路接管成功 - dsh 那次回信准确说出了界面上聊过的内容 → 上下文确实装回来了 - 第二封复用同一条本侧会话,平台侧无新增改名条目 - 回归:opencode 普通 .new + 别名续谈 + used_rounds=0(免配额通道未受影响) ## 其他 pi-mail-bridge 补 systemd 单元(此前是 setsid 裸进程,重启机器不会拉起): 陈锁清理 ExecStartPre、MemoryMax=4G、TimeoutStopSec=10。 配置目录必须与 opencode 分开(共用会让后起的读到对方密钥或撞单实例锁)。 PLUGIN-CONTRACT.md 加 B-3.7 / B-3.8 + new_mail 字段表 + 检查清单验收项。 测试:repo +10 例(adopt_test.go);三插件各 +7 例(adopt.test.mjs)
This commit is contained in:
@ -21,6 +21,7 @@ import {
|
||||
noteExplicitSend,
|
||||
shouldSkipAutoRelay,
|
||||
} from '../lib/relay-dedup.js';
|
||||
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
|
||||
import {
|
||||
userMessage,
|
||||
replySubject,
|
||||
@ -570,10 +571,89 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
|
||||
// ─── 投递邮件到 DSH 会话 ───
|
||||
|
||||
/**
|
||||
* 投进「被接管的平台会话」时给模型的提示词。
|
||||
*
|
||||
* 与新建会话那份的差别:不自我介绍身份、不解释邮件系统 —— 这条会话里人已经
|
||||
* 在谈别的事了,一段「你是 dsh,你收到一封邮件」的开场白会让模型以为上下文
|
||||
* 被重置。只说「有封邮件进来了」。
|
||||
*/
|
||||
function adoptPrompt(data: any, kind: string): string {
|
||||
if (kind === 'permission') {
|
||||
return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`;
|
||||
}
|
||||
return [
|
||||
`本会话收到一封新邮件(AgentMail)。`,
|
||||
``,
|
||||
`发件人:${data.from_name || 'unknown'}`,
|
||||
`主题:${data.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||||
``,
|
||||
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
|
||||
`回信不用你自己发:把这一轮做完、把结论说出来就行,`,
|
||||
`插件会在轮次结束时把你最后那段话发回给 ${data.from_name || '发件人'}(不消耗配额)。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下「这条邮件会话 ↔ 这条平台会话」的绑定与回信上下文。
|
||||
*
|
||||
* mailDrivenSessions 必须加:接管之后这条会话**开始**参与邮件往来,轮次结束
|
||||
* 要把总结转回发件人。不加的话邮件投进去了却永远没有回音。
|
||||
*/
|
||||
function bindAdopted(mailSessionID: string, dshSessionId: string, cwd: string, data: any): void {
|
||||
if (!mailSessionID) return;
|
||||
sessionMap.set(mailSessionID, { dshSessionId, directory: cwd });
|
||||
reverseMap.set(dshSessionId, mailSessionID);
|
||||
mailDrivenSessions.add(dshSessionId);
|
||||
mailContexts.set(mailSessionID, {
|
||||
replyTo: data.from_name || '',
|
||||
subject: data.subject || '',
|
||||
mailID: data.mail_id || '',
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> {
|
||||
const mailSessionID = data.session_id;
|
||||
const existing = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
|
||||
|
||||
// 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 DSH 界面上
|
||||
// 开的那种)—— 投进它而不是新建。
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。补全早就把平台
|
||||
// 会话列为候选(session-snapshot 上报的那批),这一跳补上投递侧。
|
||||
//
|
||||
// DSH 上不需要新代码路径:`startAgent` 本来就「磁盘上有就 resume」,
|
||||
// 接管只是把会话 id 从 `mail-<uuid>` 换成平台自己那个。第一次投递走
|
||||
// resume 分支装回上下文,之后与普通续谈完全一样(sessionMap 命中 → followup)。
|
||||
const adoptedID = adoptedSessionID(data);
|
||||
if (!existing && adoptedID) {
|
||||
const onDisk = await persistedCwd(adoptedID);
|
||||
if (onDisk === undefined) {
|
||||
// 镜像是快照,可以过期:平台侧那条会话可能已经被人删了。
|
||||
// 不能落到「新开会话」那条路 —— 那会用 `mail-<uuid>` 另开一条,
|
||||
// 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的。
|
||||
throw new Error(adoptMissingMessage(adoptedID, '磁盘上已无这条会话的日志'));
|
||||
}
|
||||
return locked(adoptedID, async () => {
|
||||
const promptText = adoptPrompt(data, kind);
|
||||
const live = ctx.agents.get(adoptedID);
|
||||
if (live) {
|
||||
// 界面上正开着这条会话 —— 直接 followup,不要再 resume 一次:
|
||||
// 同一条会话两个 handle 会各自往日志里写,replay 校验过不去。
|
||||
live.followup(userMessage(promptText));
|
||||
await waitForTurnEnd(live);
|
||||
bindAdopted(mailSessionID, adoptedID, onDisk, data);
|
||||
return { sessionID: adoptedID, reused: true };
|
||||
}
|
||||
const { handle } = await startAgent(adoptedID, onDisk, attemptOrder()[0]);
|
||||
bindAdopted(mailSessionID, adoptedID, onDisk, data);
|
||||
handle.agent.followup(userMessage(promptText));
|
||||
await waitForTurnEnd(handle.agent);
|
||||
return { sessionID: adoptedID, reused: true };
|
||||
});
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const live = ctx.agents.get(existing.dshSessionId);
|
||||
if (live) {
|
||||
|
||||
Reference in New Issue
Block a user